番外01:wga 运行时架构深挖——沙箱、HITL 与组合编排
📌 本文是《从零吃透企业级 AI 平台:元景万悟源码学习手记》第一季·番外篇的第 1 篇
🔗 原理对照:回链第一季 05「Agent 推理引擎」3.0 节。05 给了三层分层概念图和 6 种类型表,但每个子系统只点到为止。本篇走到代码级。
🎯 读完本文:① 理解 factory 的注册创建机制 ② 掌握沙箱生命周期(reuse/oneshot)③ 看懂 HITL 完整事件流 ④ 理解递归组合编排的嵌套实现
⏱️ 预计阅读时间:35 分钟 | 动手实践:40 分钟
⚠️ 诚实说明:
pkg/wga/internal/的子目录和文件名从 README 架构图推断,代码示例为教学重建版。真实路径在pkg/wga/和pkg/wga-sandbox/下。react 类型基于 eino adk,sandbox 类型基于 opencode,万悟做的是集成+编排+工程化封装。
一、这篇文章要解决什么问题?
第一季 05 的 3.0 节给你看了一眼 wga 运行时架构的全貌——6 种智能体类型、三层分层、沙箱容器、HITL 暂停恢复——然后立刻收了回去,改为详细拆解 react 类型的 ReAct 循环。
这很合理:吃一个汉堡先咬中间的肉饼,但肉饼外面还有面包、生菜、酱料。
今天我们把肉饼之外的部分补上。三个核心问题:
-
Factory 怎么工作? 6 种智能体类型不是 6 个独立的
func——它们通过一个统一的注册表注册,运行时按配置动态创建。这个注册-创建-配置链路到底长什么样? -
沙箱容器的生命周期? "在容器里跑 Agent 代码"听上去很高级,但容器怎么启动?执行完成后的文件怎么取回来?reuse 模式和 oneshot 模式在代码层面有什么区别?
-
HITL 怎么实现暂停/恢复? 不是说"暂停"吗——在 Go 的协程模型里怎么让一个 goroutine 停下来等人,等完了又继续?状态保存在哪里?
读完这篇,你能对着 pkg/wga/ 的目录说出"factory 在这里、沙箱在那里、HITL 事件流长这样"。
二、核心概念:在代码之前先看清全局

2.1 三层分层的完整版

05 给了这个图:
BFF(网关层)→ pkg/wga(引擎层)→ pkg/wga-sandbox(沙箱容器层)
但 DeepWiki 核实后,真实图景比这更完整——Agent 体系的最终执行在 Python,不在 Go:
assistant-service (Go, gRPC, :8890) # Agent 配置 + 会话 + 版本快照↓
pkg/wga (Go, 引擎层) # 6 种类型编排 + 沙箱调度 + HITL↓
pkg/wga-sandbox (Go, 沙箱 API 层) # 容器生命周期管理↓
wga-sandbox 容器 (OpenCode, :4096) # 独立沙箱进程↓
agent-wanwu (Python, :15002) # 真正的 LLM 执行运行时
关键认知:Go 侧负责"这个 Agent 能用哪些工具、跑哪种类型、当前在第几轮对话";Python 侧负责"把这个 prompt 发给模型、接到流式响应、把 tool call 解析出来"。本文聚焦 Go 侧的 pkg/wga 和 pkg/wga-sandbox,Python 侧详见第一季 05 的 3.0+ 节。
2.2 Factory 模式:不是 switch-case,是注册表
如果你来实现 6 种类型的创建,直觉写法可能是:
func NewAgent(typ string) Agent {switch typ {case "react": return newReactAgent()case "sandbox": return newSandboxAgent()// ... 6 个 case}
}
wga 没用这种写法。它用一个注册表(registry)——每种类型在初始化时注册一个构造函数 func(...) (Agent, error),运行时工厂按配置里的 AgentType 查找并调用。好处:新增类型不需要改工厂的代码,只注册就行。
// 教学重建版(对应真实路径:pkg/wga/internal/factory/)
type AgentFactory struct {creators map[AgentType]CreatorFunc
}func (f *AgentFactory) Register(typ AgentType, fn CreatorFunc) {f.creators[typ] = fn
}func (f *AgentFactory) Create(typ AgentType, opts ...Option) (Agent, error) {fn, ok := f.creators[typ]if !ok { return nil, fmt.Errorf("unknown agent type: %s", typ) }return fn(opts...)
}
2.3 沙箱两种模式:reuse vs oneshot
| 模式 | 容器生命周期 | 支持 HITL | 性能 | 适用场景 |
|---|---|---|---|---|
| reuse | 常驻,跨请求复用 | ✅ 支持 | 启动开销大(首次),后续快 | 需要人机交互的复杂任务 |
| oneshot | 用完即毁 | ❌ 不支持 | 每次新启动,开销固定 | 一次性工具调用、文件操作 |
reuse 模式的核心价值是支持 HITL——Agent 暂停等用户确认,容器不能销毁,因为执行状态在容器里。oneshot 模式更轻量,适合不需要交互的原子操作。
架构决策:为什么状态下沉到容器,而不是存在 Redis 或服务内存里?因为这样 BFF 完全无状态——SSE 连接断在哪个节点、HITL 回复打到哪个节点,都不影响 Agent 恢复执行。这是 wga 运行时最精妙的设计之一,我们到 3.3 和 3.4 细看。
三、源码拆解
3.1 Factory:6 种类型的注册与创建
AgentType 常量(config/const.go)
// 教学重建版(对应真实路径:pkg/wga/internal/config/const.go)
type AgentType stringconst (AgentTypeReact AgentType = "react" // 经典 ReAct 循环AgentTypeSandbox AgentType = "sandbox" // 沙箱隔离执行AgentTypeSequential AgentType = "sequential" // 子智能体串行AgentTypeLoop AgentType = "loop" // 子智能体循环AgentTypeParallel AgentType = "parallel" // 子智能体并行AgentTypeSupervisor AgentType = "supervisor" // 主管动态分派
)// 工具类型分类
type ToolCategory stringconst (ToolCategoryMCP ToolCategory = "mcp" // MCP 协议工具ToolCategorySkill ToolCategory = "skill" // 内置 SkillToolCategoryCustom ToolCategory = "custom" // 自定义工具ToolCategoryWorkflow ToolCategory = "workflow" // 工作流作为工具
)
工厂创建入口(factory/agent.go)
// 教学重建版(对应真实路径:pkg/wga/internal/factory/agent.go)
func newAgent(ctx context.Context, cfg *config.Agent, opts *option.Options) (Agent, error) {// Step 1: 从配置聚合工具tools := collectTools(cfg, opts)// Step 2: 按类型创建switch cfg.Type {case AgentTypeReact:return newReactAgent(ctx, cfg, tools, opts)case AgentTypeSandbox:return newSandboxAgent(ctx, cfg, tools, opts)case AgentTypeSequential:return newSequentialAgent(ctx, cfg, tools, opts)case AgentTypeLoop:return newLoopAgent(ctx, cfg, tools, opts)case AgentTypeParallel:return newParallelAgent(ctx, cfg, tools, opts)case AgentTypeSupervisor:return newSupervisorAgent(ctx, cfg, tools, opts)default:return nil, fmt.Errorf("unsupported agent type: %s", cfg.Type)}
}
💡 注意:上面确实是 switch-case——因为目前只有 6 种类型,switch 足够清晰。注册表模式体现在
LoadAgents加载配置时:配置文件定义一个 agents 列表,每个指定 type + tools + sub_agents,工厂遍历创建。如果未来 wga 支持插件式扩展,可以把 switch 改成注册表遍历——但当前 6 种类型,switch 是最佳方案。
3.2 配置驱动:ToolConfig / MCP / Skill
wga 的工具体系不是硬编码的——Agent 能用什么工具完全由配置文件决定,运行时注入。
Agent 配置结构体(config/agent.go)
// 教学重建版(对应真实路径:pkg/wga/internal/config/agent.go)
type Agent struct {Name string `json:"name" yaml:"name"`Type AgentType `json:"type" yaml:"type"`ModelID string `json:"model_id" yaml:"model_id"`Instruction string `json:"instruction" yaml:"instruction"` // System PromptToolConfigs []ToolConfig `json:"tool_configs" yaml:"tool_configs"`MCPConfigs []MCPConfig `json:"mcp_configs" yaml:"mcp_configs"`SkillConfigs []SkillConfig `json:"skill_configs" yaml:"skill_configs"`SubAgents []Agent `json:"sub_agents" yaml:"sub_agents"` // 递归嵌套的关键!MaxSteps int `json:"max_steps" yaml:"max_steps"`
}type ToolConfig struct {Name string `json:"name"`Description string `json:"description"`Parameters json.RawMessage `json:"parameters"` // JSON Schema
}
递归收集工具(agent_tool.go)
// 教学重建版(对应真实路径:pkg/wga/internal/config/agent_tool.go)
func CollectToolCategories(agent *Agent) map[ToolCategory][]ToolConfig {result := make(map[ToolCategory][]ToolConfig)// 1. 收集自己的工具for _, tc := range agent.ToolConfigs {result[ToolCategoryCustom] = append(result[ToolCategoryCustom], tc)}for _, mc := range agent.MCPConfigs {result[ToolCategoryMCP] = append(result[ToolCategoryMCP], toToolConfig(mc))}for _, sc := range agent.SkillConfigs {result[ToolCategorySkill] = append(result[ToolCategorySkill], toToolConfig(sc))}// 2. 递归收集子智能体的工具for _, sub := range agent.SubAgents {subTools := CollectToolCategories(&sub)for cat, tools := range subTools {result[cat] = append(result[cat], tools...)}}return result
}
💡 关键设计:工具是配置驱动的,不是代码硬编码的。意味着同一个 react Agent,给它配不同的
tool_configs就从"查知识库助手"变成"发邮件助手"——不需要改一行代码。这也是 wga "通用 Agent 引擎"的核心思想:引擎关注怎么调度,配置关注能做什么。
3.3 沙箱生命周期
沙箱通过 pkg/wga-sandbox/ 管理,核心入口在 api.go。
// 教学重建版(对应真实路径:pkg/wga-sandbox/api.go)
type Sandbox interface {Run(ctx context.Context, opts ...SandboxOption) (*RunResult, error)Cleanup(ctx context.Context, runID string) error
}type RunResult struct {RunID stringOutput stringFiles []FileEntryDuration time.Duration
}
生命周期四阶段
Prepare Execute CopyFrom Cleanup│ │ │ ││ ┌──────────────────┐ │ ┌───────────────┐ │ │├─│ 拉取/复用镜像 │ ├──│ Agent 代码执行 │ │ ┌───────────────┐ ││ │ 挂载卷 │ │ │ 工具调用 │────├──│ 取回执行结果 │ ││ │ 注入环境变量 │ │ │ 文件操作 │ │ │ 提取产物文件 │──┤│ └──────────────────┘ │ └───────────────┘ │ └───────────────┘ ││ │ │ ││ reuse: 首次挂载 │ │ reuse: 不销毁 ││ oneshot: 每次重建 │ │ oneshot: 销毁 │▼ ▼ ▼ ▼
沙箱选项(wga-sandbox-option/)
// 教学重建版
// reuse 模式:指定已有容器
sandbox.WithSandbox(sandbox.Reuse(containerHost))// oneshot 模式:指定镜像名
sandbox.WithSandbox(sandbox.Oneshot("wga-sandbox-base"))
架构决策:为什么状态下沉到容器?
传统做法:Agent 执行状态(当前步骤、中间变量、工具调用结果)存在 Redis 或服务内存里。HITL 需要暂停时,写一条 Redis 记录"等待用户确认",然后阻塞等待。
wga 的做法:状态不存副存储,直接留在沙箱容器里。Agent 暂停 → 容器不销毁、进程挂起 → 用户回复 → 容器进程唤醒继续。BFF 完全无状态——SSE 连接断在 BFF-A、用户回复打到 BFF-B,都不影响 Agent 恢复。因为没有依赖外部存储的"暂停状态"需要恢复。
代价:reuse 模式下容器常驻,需要资源;oneshot 模式不支持 HITL(容器一销毁状态就没了)。但架构上的简洁性——不需要 Redis 做状态中转、不需要会话粘性——对于企业级部署的价值远超这个代价。
3.4 HITL 完整事件流
HITL(Human-In-The-Loop)让 Agent 在关键步骤停下来等用户确认。wga 的实现用了 Effect-TS 风格的 Deferred 机制。
事件流时序
BFF (SSE) Agent (Go) Sandbox (容器) User│ │ │ ││ SSE: thinking │ │ ││◄───────────────────────┤ │ ││ │ Run(instruction) │ ││ ├───────────────────────►│ ││ │ │ "我要删这个文件?" ││ │ question.asked │ ││ │◄───────────────────────┤ ││ SSE: question.asked │ │ ││ {question_id, text} │ │ ││◄───────────────────────┤ │ ││ │ 协程挂起 (Deferred) │ 容器进程挂起 ││ │ │ ││ HTTP POST /reply ││◄───────────────────────────────────────────────────────────────────┤│ │ ReplyQuestion(id) │ ││ ├───────────────────────►│ ││ │ │ 唤醒、继续执行 ││ │ question.replied │ ││ │◄───────────────────────┤ ││ SSE: question.replied │ │ ││◄───────────────────────┤ │ │
核心接口(api_question.go)
// 教学重建版(对应真实路径:pkg/wga-sandbox/api_question.go)
type QuestionHandler interface {// ReplyQuestion 用户回复确认ReplyQuestion(ctx context.Context, questionID string, answer string) error// RejectQuestion 用户拒绝RejectQuestion(ctx context.Context, questionID string, reason string) error
}
Deferred 机制(教学重建版伪代码)
// 核心思想:用 channel 实现协程级阻塞
type Deferred[T any] struct {result chan Terr chan error
}func (d *Deferred[T]) Await() (T, error) {select {case r := <-d.result: return r, nilcase e := <-d.err: return zero[T](), ecase <-time.After(5*time.Minute): return zero[T](), ErrTimeout}
}// Agent 执行到高危操作时:
func (a *Agent) execRiskyOp(question string) error {d := &Deferred[bool]{result: make(chan bool, 1)}a.pendingQuestions.Store(questionID, d)// 通过 SSE 推送问题给前端a.stream.Push(QuestionAskedEvent{ID: questionID, Text: question})// 协程挂起——等用户回复confirmed, err := d.Await()if err != nil || !confirmed {return ErrUserRejected}// 继续执行return a.continueExecution()
}// BFF 收到用户 HTTP POST /reply 时:
func (a *Agent) handleReply(questionID, answer string) {d := a.pendingQuestions.Load(questionID)d.result <- true // 唤醒挂起的协程
}
💡 关键设计:Go runtime 的 goroutine 挂起成本极低(~2KB 栈),远低于 REST API 轮询。不需要 Redis,不需要消息队列,不需要定时任务——就是一个 channel 阻塞。这是 wga HITL 实现最精巧的地方。
3.5 递归组合编排
"supervisor 的 sub_agents 可以是 parallel,parallel 下面又可以挂多个 react"——05 说的这句话,在代码里通过 SubAgents []Agent 字段和 CollectToolCategories 的递归实现。
// 组合示例
supervisorCfg := &Agent{Name: "合同审查主管",Type: AgentTypeSupervisor,SubAgents: []Agent{{Name: "并行检索组",Type: AgentTypeParallel,SubAgents: []Agent{{Name: "法规检索", Type: AgentTypeReact, ToolConfigs: []ToolConfig{{Name: "search_fagui"}}},{Name: "案例检索", Type: AgentTypeReact, ToolConfigs: []ToolConfig{{Name: "search_anli"}}},},},{Name: "合同审查沙箱",Type: AgentTypeSandbox,ToolConfigs: []ToolConfig{{Name: "review_contract"}},},},
}
// 最终工具集合:CollectToolCategories(supervisorCfg) 会递归收集
// → {custom: [review_contract], mcp: [], skill: [], workflow: []}
不是魔法,是递归嵌套。 supervisor 收到任务 → 分析拆解 → 分发给 sub_agents → 汇总结果。parallel 收到任务 → 同时启动两个 react → 等全部完成 → 合并结果。每一层都是一个独立的 Agent 实例,有自己的工具、自己的循环次数限制。
3.6 AG-UI 协议转换
wga 引擎内部的事件(AgentEvent)需要转换成前端可消费的 AG-UI 事件格式。这个转换由 pkg/ag-ui-util/ 完成。
Agent 内部事件 AG-UI 事件
─────────────────────────────────────────────
AgentStartEvent → RUN_STARTED
ThoughtEvent → REASONING_MESSAGE
ToolCallEvent → TOOL_CALL_START → TOOL_CALL_END
ToolResultEvent → TOOL_CALL_RESULT
FinalAnswerEvent → TEXT_MESSAGE
AgentEndEvent → RUN_FINISHED
ErrorEvent → RUN_ERROR
QuestionAskedEvent → STATE_SNAPSHOT (HITL 暂停)
QuestionRepliedEvent → STATE_DELTA (HITL 恢复)
两个 Translator 各司其职:
- EinoTranslator:把 eino adk 的 AgentEvent 流转换为 AG-UI 事件
- OpencodeTranslator:把 OpenCode 容器的 JSON 输出转换为 AG-UI 事件
stream_processor.go 负责事件流的清洗和聚合——连续的小文本块合并成完整消息、心跳事件过滤、重复事件去重。
四、动手实操
在万悟平台验证以下场景:
1. 观察 factory 创建链
# 创建一个 supervisor 类型的智能体,配 2 个 react 子智能体
# 观察服务日志中的创建序列:[INFO] factory: creating agent type=supervisor name=主控
[INFO] factory: creating agent type=react name=法规检索 sub_of=主控
[INFO] factory: creating agent type=react name=案例检索 sub_of=主控
[INFO] agent 主控: collected 4 tools from 3 agents
2. 触发一次 HITL
在 Agent 配置中开启高危操作确认:
- 配置一个工具
delete_record,标签设为risky - 向 Agent 发送:"帮我删除 2023 年的所有旧合同记录"
- 观察 SSE 事件流中
question.asked事件的出现 - 在前端回复"确认"后观察
question.replied和后续执行
3. 对比 reuse vs oneshot
# 查看沙箱容器状态
docker ps | grep wga-sandbox# reuse 模式下:容器在多次请求间保持运行
# oneshot 模式下:每次请求创建新容器,完成后销毁
五、Mini 版 / 踩坑录
Mini 版:简易 factory + sub_agents(~60 行 Go)
// 教学重建版——演示 factory 注册 + 递归组合的核心骨架
package maintype AgentType string
type CreatorFunc func(name string, tools []string) Runnertype Runner interface {Run(ctx context.Context, input string) (string, error)
}type Factory struct {creators map[AgentType]CreatorFunc
}func (f *Factory) Register(typ AgentType, fn CreatorFunc) {f.creators[typ] = fn
}type AgentConfig struct {Name stringType AgentTypeTools []stringSubAgents []AgentConfig
}func (f *Factory) Build(cfg AgentConfig) Runner {// 先创建自己runner := f.creators[cfg.Type](cfg.Name, cfg.Tools)// 再递归创建子智能体if len(cfg.SubAgents) > 0 {subRunners := make([]Runner, len(cfg.SubAgents))for i, sub := range cfg.SubAgents {subRunners[i] = f.Build(sub) // 递归!}// 注入到 supervisor/parallel 中if composable, ok := runner.(ComposableRunner); ok {composable.SetSubAgents(subRunners)}}return runner
}
踩坑录
| 踩坑 | 现象 | 原因 | 解法 |
|---|---|---|---|
| reuse 单实例瓶颈 | 并发请求到同一个 reuse 容器时排队 | 一个容器同时只能跑一个 Agent | 用容器池或 oneshot 模式 |
| oneshot 不支持 HITL | 配置了 HITL 但不生效 | oneshot 容器用完即毁,状态丢失 | HITL 场景必须用 reuse |
| 递归深度过大 | supervisor → parallel → supervisor → ... 爆栈 | 没有限制嵌套深度 | 生产配置限制 max_depth=3 |
| 工具描述太弱 | Agent 频繁选错工具 | LLM 根据 description 选择,描述模糊就选错 | 工具描述写清楚输入/输出/使用时机 |
⚠️ 诚实标注:wga 的 react 类型基于 eino adk(字节跳动开源的 LLM 应用框架),sandbox 类型依赖 opencode(外部项目的容器执行能力)。万悟做的是集成 + 编排 + 工程化封装——把成熟组件组装成适应 11 微服务 + 多租户 + 多渠道的企业级 runtime。
六、总结 & 延伸阅读
⏱️ 30 秒速览
这篇你只需要记住 3 件事:
- 万悟 Agent 是三层架构:assistant-service(配置)→ pkg/wga(编排)→ agent-wanwu(Python 执行 LLM)
- 配置驱动:改 tool_configs 就能把「查知识库助手」变成「发邮件助手」,不改代码
- HITL 用 Go channel 协程级阻塞(~2KB),状态下沉到容器,BFF 无状态
想深挖?
- 引擎与沙箱实现:§2 核心概念;完整源码见 GitHub
pkg/wga与agent-wanwu
本文要点回顾
- ✅ 三层架构:assistant-service(配置) → pkg/wga(编排) → agent-wanwu(Python, LLM执行)
- ✅ Factory 模式:6 种类型通过 switch 分发创建,工具通过配置驱动运行时注入
- ✅ 配置驱动:改
tool_configs就能把"查知识库助手"变成"发邮件助手",不改代码 - ✅ 沙箱两种模式:reuse(常驻、支持 HITL、首次启动慢)vs oneshot(用完即毁、无 HITL、每次重建)
- ✅ 状态下沉:Agent 执行状态留在容器里,BFF 完全无状态——不需要 Redis、不需要会话粘性
- ✅ HITL:基于 Go channel 的协程级阻塞(~2KB 开销),不需要消息队列或定时轮询
- ✅ 递归组合:Supervisor→Parallel→React 的嵌套,通过
SubAgents []Agent字段 +CollectToolCategories递归实现 - ✅ AG-UI 转换:EinoTranslator / OpencodeTranslator 把内部事件转为前端可消费的 AG-UI 事件流
架构决策回顾
| 决策 | 选了 | 弃了 | 为什么 |
|---|---|---|---|
| 状态存哪 | 沙箱容器内 | Redis / 服务内存 | BFF 无状态可水平扩展,HITL 恢复不依赖外部存储 |
| 沙箱模式 | reuse + oneshot 双模式 | 纯 oneshot | HITL 需要常驻容器,一次性任务不需要 |
| 工具注入 | 配置驱动 | 代码硬编码 | Agent 类型不变,能力可热切换 |
| 协程阻塞 | Go channel Deferred | 轮询 / 消息队列 | 轻量(~2KB)、简单(无外部依赖)、可靠 |
| 类型分发 | switch-case | 注册表 | 当前只有 6 种,switch 最清晰;未来扩展加注册表 |
延伸阅读
- 第一季 05「Agent 推理引擎」—— ReAct 循环的详细拆解(本篇的前置篇)
- 第一季 05 §2.4「安全护栏」—— Agent 不能"为所欲为"(HITL 的动机)
- eino adk —— wga react 类型的底层框架
pkg/wga/DESIGN.Human-In-The-Loop.md—— 万悟仓库中的 HITL 设计文档
📱 关注公众号,追更不迷路
本系列文章首发于微信公众号「农夫三拳有点癫」,每周更新源码拆解与架构实战。
在微信扫描下方二维码即可关注:
