--- title: "Rein:4 模块 + 5 类型边界防止 agent.go 膨胀到 3000 行" source_url: "https://mp.weixin.qq.com/s/P9hUlN3Nv7EdJqxuDbLsJQ" publish_date: 2026-06-04 tags: [wechat, article, agent, harness, go, architecture, type-boundary, modularity, tool-spec, session-replay, security, rein] review_value: 9 review_confidence: 9 review_recommendation: strong sha256: pending --- # Rein:4 模块 + 5 类型边界防止 agent.go 膨胀到 3000 行 > 整理:Hermes Agent > 原文:https://mp.weixin.qq.com/s/P9hUlN3Nv7EdJqxuDbLsJQ > 项目:Rein(Go agent 框架)· 系列第 1 篇("下一篇:安全三明治的四层防线"未完待续) ## 一句话定位 **Rein = 一个 Go agent 框架,用 4 个模块 + 5 条类型边界 解决"agent.go 200 行 → 3000 行"的问题**。核心思路:**模块之间的数据契约定义清楚 = 防止上帝文件**。每条数据流都对应一个**严格类型 + 单一职责 + 不可见字段**。 > "根子在哪?不是架构图不够漂亮。是模块之间的数据契约没定义清楚。" ## 核心问题 2026 年大部分 Go 开发者都能用 200 行写一个"调 LLM → 解析 tool call → 执行工具 → 喂回结果"的 agent。但写到 2000 行时开始失控: - 加 MCP 工具支持 → agent loop 要改 - 加沙箱隔离 → agent loop 又要改 - 加上下文压缩 → agent loop 还得改 - 到最后 `agent.go` 变成 **3000 行的上帝文件**,模块边界消失 ## 4 模块分工 | 模块 | 做什么 | 不做什么 | |---|---|---| | **agent** | 编排 loop 流程、调度工具、生成 observation | 不实现工具、不决定权限、不做 UI | | **provider** | 调用 LLM、处理流式、重试和 fallback | 不执行工具、不做 policy 决策、不持久化 | | **tools** | 注册工具、暴露 Spec、Dispatch 执行 | 不决定何时调用、不做 schema 校验、不做 policy | | **context** | 组装 Request、压缩历史、注入 memory/skills | 不改写原始 logical_messages、不执行 policy | **关键约束**:agent 只依赖接口,不依赖具体实现。**18 个 Option 全部可选**——每个依赖都可被 mock、替换或设为 noop。^[raw/articles/rein-go-agent-4-modules-5-type-boundaries.md] ## 5 条类型边界(数据契约) | 流向 | 类型 | 定义方 | 携带什么 | |---|---|---|---| | agent → provider | `provider.Request` | provider | 组装好的系统提示、压缩后历史、tool specs | | provider → agent | `provider.Response` | provider | assistant message(可能含 tool_calls)+ usage | | agent → tools | `Tool`(通过 Registry) | tools | 完整定义——agent 读 Spec 给 provider,读 Metadata 用于 policy | | tools → agent | `tools.Result` | tools | 统一的 success/error + 可选 Data/Artifacts | | agent → context | `ctxbuild.Input` | context | 原始历史 + memory + skills + tool specs | | context → agent | `provider.Request` | provider | 压缩/组装后的最终请求 | **贯穿所有边界的红线**:工具执行 metadata(risk、timeout、execution_mode、side_effect)**只在 tools 包中存在**,agent 读取后用于 policy/execpolicy 决策,但**永远不会出现在 provider.Request.Tools 中**。^[raw/articles/rein-go-agent-4-modules-5-type-boundaries.md] ## 7 个不变量(核心循环的"宪法") 1. **工具失败不终止 loop**:未知工具、panic、timeout、deny、block 全部转为 observation 2. **最终输入安全**:任何 tool input 经过 parse → schema → policy → execpolicy → sandbox 五道关口 3. **流式和同步路径等价**:provider 保证最终 Message 内容一致 4. **Session 原子写入**:每轮 step 的 messages + events 同一次 Commit 5. **observation 顺序确定**:并行工具追加顺序 = provider 返回的 tool_call 原始顺序 6. **Context 构建不改写历史**:压缩和注入发生在投影上 7. **Panic 隔离**:单个 tool 或 hook panic 不崩溃整个 loop > 这七条一旦违反任何一条,agent loop 的可靠性就不再成立。其他模块(policy、sandbox、gateway)可以换实现,**这七条不能动**。^[raw/articles/rein-go-agent-4-modules-5-type-boundaries.md] ## 三大核心设计模式 ### 1. ToolSpec vs Tool(安全边界,不是封装好习惯) **模型只看到 ToolSpec(三字段)**: ```go type ToolSpec struct { Name string Description string InputSchema json.RawMessage } ``` **完整 Tool 有三部分**——Spec(模型可见)+ Metadata + Handler(模型不可见): ``` ┌─────────────────────────────────────┐ │ 模型可见(ToolSpec) │ │ name: "shell_exec" │ │ description: "执行 shell 命令" │ │ input_schema: {command: string} │ ├─────────────────────────────────────┤ │ 模型不可见(Metadata + Handler) │ │ risk: "exec" │ │ execution_mode: "sequential" │ │ timeout: 60s │ │ side_effect: true │ │ handler: func(input) Result { ... }│ └─────────────────────────────────────┘ ``` **为什么重要**: - ❌ **直接传 Tool 给模型**:模型收到完整 Tool 定义 → 知道 shell_exec 的 risk=exec → 可通过 prompt injection 推断"这个工具很危险,可能被拦截" → 调整调用策略绕过安全检测 - ✅ **传 ToolSpec**:模型只知道"有一个 shell_exec 工具,接受 command 参数" → 不知道会被 sandbox 隔离 → 不知道用户需要 approve → **安全信息完全在模型视野之外** `tools.Registry.Specs()` 是 agent 获取工具列表的**唯一入口**,返回类型是 `[]provider.ToolSpec`,不是 `[]tools.Tool`。**无论新增多少工具、无论工具的 risk 怎么配置,模型看到的始终只是 name + description + input_schema**。^[raw/articles/rein-go-agent-4-modules-5-type-boundaries.md] ### 2. Provider 流式 = 同步(等价性约束) **Stream() 最终组装的 Message 必须和 Complete() 的返回值完全一致**——这是 provider 层写在 invariants 里的第 7 条规则。 **为什么重要**:agent 的后处理逻辑(tool dispatch、session commit、event 记录)全部基于最终的完整 Message。**如果流式和非流式路径产出不同的 message,agent 就要维护两套处理逻辑**。 **Rein's 哲学**:**等价性由 provider 层保证,上层不需要做任何判断**。 - 对比:Claude Code 的 SSE 推送由调用方自行维护增量状态来组装最终结果(更复杂) - 对比:OpenAI Agents 的 SDK events 类似(调用方处理) - Rein 的做法更简洁:channel 一定 close、tool_call_delta 按 index 归组、agent 只把 content_delta 转发给 UI sink ^[raw/articles/rein-go-agent-4-modules-5-type-boundaries.md] ### 3. 投影式压缩(Context 不改写历史) **问题**:直接删改 logical_messages → session 回放时只有残缺历史 → eval 无法基于完整对话复现 bug → debug 时看不到被压缩掉的那段对话 **Rein 解法**: ``` ✅ 投影: projection = compress(logical_messages) // logical_messages 原样保留 → session 记录完整历史 → 回放/eval/debug 基于完整对话 → 只有发送给 LLM 的那份是压缩的 ``` **三级压缩管道**: - **第一级(滑动窗口)**:保留最近 N 条消息(默认 50),丢弃最老的。硬约束:永远不丢弃 system prompt、永远不丢弃当前 step。丢弃顺序:先丢 tool result,再丢 user/assistant pair - **第二级(Token 截断)**:维护 token 预算(默认 8000),预留 30% 给响应。从最旧的 tool result 开始丢弃。token 估算用字符启发式:英文 4 字符 ≈ 1 token,中文 1.5 字符 ≈ 1 token - **第三级(摘要)**:前两级不够时,用轻量模型把被丢弃的消息压缩为 system note。**摘要失败 → 退回到纯截断。宁可少信息,不阻塞 agent**。^[raw/articles/rein-go-agent-4-modules-5-type-boundaries.md] **对比 Claude Code compaction**: - Claude Code:compaction 生成摘要后**替换旧消息**——压缩后原始对话不再完整保留 - Rein:**存储完整,发送压缩**——多占一点磁盘,换来完全确定的回放 ## 18 个 Option 的安全默认 ```go WithSystem(prompt) // 必填 WithMessages(messages...) // 对话历史 WithTools(toolList...) // 构造 Registry WithModel(name) // 模型名 WithMaxSteps(8) // 最大步数,默认 8 WithToolTimeout(30s) // 工具超时,默认 30s WithMaxParallelTools(4) // 并行上限,默认 4 WithPolicy(decider) // 未注入 → AllowReadOnly WithApproval(func) // 人工审批 WithExecPolicy(engine) // 未注入 → dev-only local WithSandbox(s) // 未注入 → warning event WithHooks(bus) // 未注入 → noop WithGuardrails(g) // 未注入 → noop WithSession(store) // 未注入 → 不持久化 WithContextBuilder(builder) // 未注入 → 简单拼接 WithStreaming(true/false) // 是否流式 WithStreamSink(sink) // UI delta 回调 WithInterruptMode(mode) // 中断模式 WithInterrupts(ch) // 中断通道 WithWorkingDir(cwd) // 工作目录 ``` **除 WithSystem 必填外,其余 17 个全部有默认值**。只写 `WithSystem + WithTools` 就能跑一个完整的 agent loop——其余全部走**最安全的默认路径**:read 放行、write 需确认、exec 拒绝。^[raw/articles/rein-go-agent-4-modules-5-type-boundaries.md] ## Observation Envelope:一切失败回传模型 ```go // 工具成功 {"type": "tool_result", "message": "wrote 127 bytes to main.go", "retryable": false} // 工具不存在 {"type": "error", "code": "unknown_tool", "message": "...", "retryable": true} // policy 拒绝 {"type": "denied", "message": "shell_exec requires approval", "retryable": true} // hook 阻止 {"type": "blocked", "message": "path outside workspace", "retryable": true} // 超时(side_effect_unknown 已在事件中标记) {"type": "timeout", "code": "tool_timeout", "message": "...", "retryable": true} ``` **retryable 字段的精华**:它告诉模型——这个错误有可能换个方式成功(denied → 换一个只读工具?timeout → 拆分任务?),还是绝对不可修复(guardrail_output_blocked → 别试了)。 **Rein 的 agent loop 只有 3 种情况会 return error**: 1. provider terminal error(重试耗尽了) 2. session 持久化失败(Commit 返回 error) 3. 非法配置(system 为空、重复 tool name 等) **其余一切**——工具 panic、policy deny、hook block、guardrail fail、timeout——**全部转为 observation envelope,由模型决定下一步**。^[raw/articles/rein-go-agent-4-modules-5-type-boundaries.md] > **Rein's 哲学 vs Claude Code / OpenAI Agents**: > - **Claude Code**:工具错误**中断当前 task** 并抛给外层 handler,由 handler 决定是否重试整个 task > - **OpenAI Agents**:通过 SDK event 通知调用方工具失败了,由调用方写重试逻辑 > - **Rein**:让模型自己看到错误、自己决定怎么办。"shell_exec 被 deny 了?那我用 read_file + search_code 组合试试"——**模型有时候能想到你没想到的替代方案** ## M1 最小工具集 | 工具 | Risk | ExecutionMode | 说明 | |---|---|---|---| | read_file | read | parallel | 受 cwd + path policy 约束 | | search_code | read | parallel | 基于 ripgrep,输出截断 | | write_file | write | sequential | 默认需 ask approval | | shell_exec | exec | sequential | 受 hard-deny 列表保护 | **4 个工具,3 个 risk 等级,2 种执行模式**——刚好覆盖一个最小 agent 需要的全部动作。 **并行调度规则**: - `parallel` 工具可和其他 parallel 工具并发执行(受 `max_parallel_tools` 限制,默认 4) - `sequential` 工具是**调度 barrier**——它前面的 parallel batch 必须先完成,它执行期间后续工具不能穿越 - **observation 追加顺序始终按 provider 返回的 tool_call 原始顺序**,而不是并行完成顺序——保证 session 回放的确定性 ^[raw/articles/rein-go-agent-4-modules-5-type-boundaries.md] ## Metadata 的三个默认值(纵深防御) | 字段 | 空时默认 | 设计意图 | |---|---|---| | **Risk** | `read` | 即使开发者忘记标注,工具也不会以 exec 权限运行 | | **ExecutionMode** | `parallel` | 大多数读取类工具可以安全并行 | | **Timeout** | agent 全局 `WithToolTimeout`(默认 30s) | 不让单工具无界运行 | > 这不是"防御性编程",是**纵深防御**。每一个没被显式设置的字段,都落在**最安全的一端**。^[raw/articles/rein-go-agent-4-modules-5-type-boundaries.md] ## 复检链:Hook 改输入后必须重跑 **关键细节**:当 pre-tool hook 把输入改了(updated_input),**agent 不是直接信任——它重跑 schema → guardrail → policy**。因为 hook 可能把"读 /safe/path"改成"读 /.env",不重跑 policy 就永远不会知道。 这条复检链是 **agent loop 7 步骤里的细节**: - 7b. JSON parse + schema 校验 - 7c. policy.Decide() → deny / ask / allow - 7d. pre-tool hooks → block / **updated_input**(后重新跑 7b + 7c,避免 hook 绕过 policy) - 7e. execpolicy.Plan() - 7f. Dispatch() ^[raw/articles/rein-go-agent-4-modules-5-type-boundaries.md] ## 与现有 wiki 实体的关系 ### vs Claude Code 源码分析([[entities/claude-code-20000-char-source-analysis]]) - Claude Code 98.4% 基础设施 + 1.6% AI 决策 - Rein 用 4 模块 + 5 类型边界把 3000 行结构化 ### vs Agent Harness 上下文管理([[entities/agent-harness-context-management-working-set]]) - 工作集视角:logical_messages 原样保留 + 投影 = Rein 的"存储完整,发送压缩" ### vs wow-harness v3 事件溯源([[entities/wow-harness-v3-governance-protocol]]) - v3 = 跨 session 事件时间线 + 概念图 - Rein = session 原子写入 + 完整 logical_messages - **共同点**:完整历史 + 不可篡改 + 确定性回放 ### vs PilotDeck 白盒记忆([[entities/pilotdeck-agent-os-openbmb-tsinghua]]) - PilotDeck 记忆可读可改 + Dream 回滚 - Rein observation envelope retryable 字段 - **共同点**:把"AI 思考过程"暴露给用户/调用方 ## 核心断言 > **"模块之间的数据契约定义清楚"是防止 agent.go 膨胀到 3000 行的根本解**。 > **"安全信息(risk / timeout / execution_mode)永远不出现在 provider.Request.Tools 中"是模型安全边界的物理保证**。 > **"让模型看到错误、自己决定怎么办"比"框架重试整个 task"更灵活**。 ## 启示 1. **数据契约 > 架构图** —— 4 模块图很常见,但 5 条类型边界是真正起作用的(agent 只依赖接口 + 18 Option 可注入) 2. **ToolSpec vs Tool 是物理安全边界** —— 不是"封装好习惯",是"模型看不看得见安全信息"的根本区别 3. **存储完整 + 发送压缩** —— 多占一点磁盘换完全确定的回放(vs Claude Code 的"压缩替换") 4. **Provider 流式=同步** —— 等价性约束写在 provider 层 invariants 第 7 条,上层零复杂度 5. **Hook 改输入后必须重跑 policy** —— 复检链是"hook 不被绕过"的根本保证 6. **Observation envelope + retryable** —— 让模型参与错误恢复决策(vs 框架重试整个 task) 7. **18 Option 安全默认** —— "未注入 → 最安全路径"是降低集成成本的关键 ## 下篇预告 > "当 agent 决定调用 shell_exec 后,policy 如何判断该不该执行?execpolicy 如何规划 sandbox 和资源限制?sandbox 如何实施隔离?guardrails 如何校验最终输出?——**安全三明治的四层防线**。"