三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

S1-番外篇-01-Agent 引擎的隐藏层:6 种类型 + 沙箱 + HITL 全拆

S1-番外篇-01-Agent 引擎的隐藏层:6 种类型 + 沙箱 + HITL 全拆

番外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 循环。

这很合理:吃一个汉堡先咬中间的肉饼,但肉饼外面还有面包、生菜、酱料。

今天我们把肉饼之外的部分补上。三个核心问题:

  1. Factory 怎么工作? 6 种智能体类型不是 6 个独立的 func——它们通过一个统一的注册表注册,运行时按配置动态创建。这个注册-创建-配置链路到底长什么样?

  2. 沙箱容器的生命周期? "在容器里跑 Agent 代码"听上去很高级,但容器怎么启动?执行完成后的文件怎么取回来?reuse 模式和 oneshot 模式在代码层面有什么区别?

  3. HITL 怎么实现暂停/恢复? 不是说"暂停"吗——在 Go 的协程模型里怎么让一个 goroutine 停下来等人,等完了又继续?状态保存在哪里?

读完这篇,你能对着 pkg/wga/ 的目录说出"factory 在这里、沙箱在那里、HITL 事件流长这样"。


二、核心概念:在代码之前先看清全局

**wga 三层架构图(assistant-service → pkg/wga → agent-wanwu)**

2.1 三层分层的完整版

**三层协作时序图(配置加载 → 引擎编排 → Python 执行 LLM → 流式返回)**

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 件事:

  1. 万悟 Agent 是三层架构:assistant-service(配置)→ pkg/wga(编排)→ agent-wanwu(Python 执行 LLM)
  2. 配置驱动:改 tool_configs 就能把「查知识库助手」变成「发邮件助手」,不改代码
  3. HITL 用 Go channel 协程级阻塞(~2KB),状态下沉到容器,BFF 无状态

想深挖?

  • 引擎与沙箱实现:§2 核心概念;完整源码见 GitHub pkg/wgaagent-wanwu

本文要点回顾

  1. 三层架构:assistant-service(配置) → pkg/wga(编排) → agent-wanwu(Python, LLM执行)
  2. Factory 模式:6 种类型通过 switch 分发创建,工具通过配置驱动运行时注入
  3. 配置驱动:改 tool_configs 就能把"查知识库助手"变成"发邮件助手",不改代码
  4. 沙箱两种模式:reuse(常驻、支持 HITL、首次启动慢)vs oneshot(用完即毁、无 HITL、每次重建)
  5. 状态下沉:Agent 执行状态留在容器里,BFF 完全无状态——不需要 Redis、不需要会话粘性
  6. HITL:基于 Go channel 的协程级阻塞(~2KB 开销),不需要消息队列或定时轮询
  7. 递归组合:Supervisor→Parallel→React 的嵌套,通过 SubAgents []Agent 字段 + CollectToolCategories 递归实现
  8. 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 设计文档

📱 关注公众号,追更不迷路

本系列文章首发于微信公众号「农夫三拳有点癫」,每周更新源码拆解与架构实战。

在微信扫描下方二维码即可关注:

账号二维码

← 返回列表