[{"content":"引子：30 行 ReAct demo 距离上线还有多远 几乎所有 agent 教程都从同一段代码开始：\n1 2 3 4 5 while True: action = llm(messages) if action.is_final: break messages.append(run_tool(action)) 三十行、一个 while、一张 ReAct prompt 图，就号称能跑任意任务。写 demo、发 demo 视频没问题，但把同样的代码丢到生产环境里跑真实编程任务，你会在一周内集齐以下 bug：模型陷入工具调用死循环、单次对话烧光当月配额、上下文撑爆 200k tokens 后直接 400、权限失控地执行了 rm -rf、线上崩了却没有任何可回放的 trace。\n工业级 agent 框架解决的正是这些\u0026quot;demo 里看不见的问题\u0026quot;。以开源实现 claw-code 为例，它把 Claude Code 的主控制流拆成了至少 8 个显式控制点：max_turns / max_budget_tokens / compact_after_turns / route_prompt / hooks / sandbox / streaming events / sidechain。claw-code 的默认值是 max_turns=8、max_budget_tokens=2000、compact_after_turns=12——这三个数字不是随便填的，是把一个\u0026quot;能跑\u0026quot;的 agent 变成\u0026quot;可以跑 1000 个 turn 不爆\u0026quot;的最小必要条件。\n这一篇不讲 prompt engineering，也不讲模型选型。我只回答两个问题：\n一个能上线的 agent 框架，比 50 行 ReAct demo 多了什么？ 如何用一张图解释 Claude Code / claw-code 的主控制流？ 下面这张图会在文中反复出现，我们围绕它展开 5 个工程学意义上的拆解。\nflowchart LR U[User prompt] --\u0026gt; R[route_prompt: token match commands+tools] R --\u0026gt; Q[QueryEngine.submit_message] Q --\u0026gt;|loop| LLM[LLM call] LLM --\u0026gt;|tool_use| TX[Tool exec pipeline] TX --\u0026gt; Q Q --\u0026gt;|stop_reason| End[(TurnResult)] Q -. \u0026gt;max_turns .-\u0026gt; End Q -. \u0026gt;budget .-\u0026gt; End Q -. compact_after .-\u0026gt; C[compact transcript] --\u0026gt; Q 一、Agent Loop 的真实形态 读源码最容易产生一种幻觉：QueryEngine.submit_message() 这么重要的函数，实现一定很\u0026quot;高级\u0026quot;。打开 src/query_engine.py，你会发现它的主干其实就是一个 for turn in range(max_turns) 的循环——和 30 行 demo 一模一样。区别不在循环本身，在循环的边界。\nclaw-code 的 submit_message 签名如下：\n1 2 3 4 5 6 7 def submit_message( self, prompt: str, matched_commands: tuple[str, ...] = (), matched_tools: tuple[str, ...] = (), denied_tools: tuple[PermissionDenial, ...] = (), ) -\u0026gt; TurnResult: 注意这里的参数：没有 messages 列表，没有 全局 history。取而代之的是一串\u0026quot;外部已经替你决策好的上下文\u0026quot;——哪些 command 命中了、哪些 tool 命中了、哪些 tool 被权限模块挡下了。也就是说，QueryEngine 本身不做路由、不做权限判定，它只做一件事：在一个 prompt / tool / budget 三重有界 的区域里推进对话。\n这个\u0026quot;有界\u0026quot;通过三种 stop_reason 显式兑现：\ncompleted——模型主动吐出 final text，正常结束。 max_turns_reached——超过最大轮数，强制中断。 max_budget_reached——累计 token 超过预算，强制中断。 为什么这三个都要写成 stop_reason 而不是 throw exception？因为任何一次 agent 运行都必须返回一个结构化结果，哪怕是失败的那种。对调用方来说，TurnResult(..., stop_reason=\u0026quot;max_turns_reached\u0026quot;) 和 TurnResult(..., stop_reason=\u0026quot;completed\u0026quot;) 在接口上等价，上游可以统一做埋点、计费、回放。让 agent\u0026quot;自由跑无停机\u0026quot;是典型的 toy 思路，在生产里等于把用户钱包交给 LLM 随机数。\n我在 mini_agent_loop.py 里把这套骨架缩成了 130 行。核心循环长这样：\n1 2 3 4 5 6 7 8 9 10 11 12 13 for turn in range(self.max_turns): yield {\u0026#34;type\u0026#34;: \u0026#34;message_delta\u0026#34;, \u0026#34;turn\u0026#34;: turn} decision = fake_llm(prompt, history) used += 50 + 10 * turn if used \u0026gt; self.max_budget_tokens: stop = \u0026#34;max_budget_reached\u0026#34; break if decision[\u0026#34;type\u0026#34;] == \u0026#34;final\u0026#34;: output = decision[\u0026#34;text\u0026#34;] break # ... tool dispatch else: stop = \u0026#34;max_turns_reached\u0026#34; Python 的 for ... else 在这里非常好用：只有当 for 自然耗尽 range(max_turns) 时 else 才触发，语义完美匹配\u0026quot;走到最后一轮都没 break 出来\u0026quot;的状态。这是工业实现里看似不起眼、但 review 时你应该立刻认出来的模式。\nInsight：Agent loop 的工程化，本质是把\u0026quot;无限制 while True\u0026ldquo;替换为\u0026rdquo;显式三元停机 + 结构化返回值\u0026quot;。剩下的所有事——重试、compact、监控——都以这个结构化返回值为支点展开。\n二、Turn 是一等对象 上一节我顺手用了 TurnResult，没有解释为什么要把\u0026quot;一次模型往返\u0026quot;抽成对象。来看 claw-code 的定义：\n1 2 3 4 5 6 7 8 9 @dataclass(frozen=True) class TurnResult: prompt: str output: str matched_commands: tuple[str, ...] matched_tools: tuple[str, ...] permission_denials: tuple[PermissionDenial, ...] usage: UsageSummary stop_reason: str 七个字段，全部 frozen=True。这意味着一个 TurnResult 一旦生成就不可变，可以安全地写进 SQLite、丢进队列、做 diff。对照 toy demo 里那个会被无限 append 的 messages: list[dict]——后者根本没法做审计。\nTurnResult 不是日志，是协议。它回答了三个问题：\n这次 turn 输入是什么？ → prompt + matched_* 这次 turn 产出了什么？ → output + permission_denials 这次 turn 花了多少？ → usage + stop_reason 有了这个协议，整个 runtime 可以往外吐一条有结构的事件流。claw-code 在 submit_message 内部通过 generator 吐出 6 种流式事件：\nmessage_start——开启 turn，带上路由命中的 tool / command 列表。 command_match——某个显式命令被路由器匹配到。 tool_match——LLM 决定调用某个 tool。 permission_denial——某个 tool 被权限层挡下。 message_delta——流式 token 推进（对应 Anthropic API 的 message_delta）。 message_stop——turn 结束，附 stop_reason。 这 6 种事件和 Anthropic 官方 Messages API 的 streaming event 名字几乎一一对应，这不是巧合——对齐上游协议，意味着调用方可以用同一套 UI 组件渲染\u0026quot;本地 tool 执行\u0026quot;和\u0026quot;远端 API 流\u0026quot;。我在 demo 里把它简化为生成器：\n1 2 3 4 5 6 7 8 def submit_message(self, prompt: str) -\u0026gt; Generator[dict, None, TurnResult]: ... yield {\u0026#34;type\u0026#34;: \u0026#34;message_start\u0026#34;, \u0026#34;matched_tools\u0026#34;: matched} for turn in range(self.max_turns): yield {\u0026#34;type\u0026#34;: \u0026#34;message_delta\u0026#34;, \u0026#34;turn\u0026#34;: turn} ... yield {\u0026#34;type\u0026#34;: \u0026#34;message_stop\u0026#34;, \u0026#34;stop_reason\u0026#34;: stop} return TurnResult(...) 注意签名：Generator[dict, None, TurnResult]。中间 yield 事件，最后 return 结果。调用方通过 StopIteration.value 拿到 TurnResult——既流式、又结构化，两头不误。\nInsight：把 Turn 抽成一等对象之后，它同时承担了三个角色——可观测（事件流）、可重放（冻结字段）、可计费（usage）。这三件事任何一件单独实现都很难；合并进同一对象之后，只要所有子系统都以 TurnResult 为总线，就自动串起来了。\n三、模型调用前的 Routing 层 打开 claw-code 的 src/runtime.py，你会看到一个容易被忽略的函数：\n1 2 3 4 5 6 7 8 9 def route_prompt(self, prompt: str, limit: int = 5) -\u0026gt; list[RoutedMatch]: ... @dataclass(frozen=True) class RoutedMatch: kind: str # \u0026#34;command\u0026#34; | \u0026#34;tool\u0026#34; name: str source_hint: str score: int 它做的事不复杂：把用户 prompt 按 / 和 - 切词，和 PORTED_COMMANDS + PORTED_TOOLS 里注册的名字做重叠匹配，打分后取 top-k。默认 limit=5。\n乍一看这层是多余的——反正 LLM 自己就会看 tool description 然后选择。但你算一下账：假设系统里注册了 200 个 tool，每个 description 平均 100 token，全量塞进 system prompt 就是 20k token，每次调用都要烧一次，即使用户只是说\u0026quot;你好\u0026quot;。Routing 层干的就是把 20k 砍到几 k：\nroute_prompt(\u0026quot;读一下 /src/main.py\u0026quot;) → 命中 ReadFile、Glob 等少数几个 tool。 只把这几个的完整 description 写进 LLM prompt。 其他 195 个工具根本不出现在这次请求里。 这是一个在 LLM 前面跑的 retrieval。你可以把它理解为\u0026quot;工具层 RAG\u0026quot;——不过通常不必上向量库，token overlap 这种朴素打分在工具名这种短文本上效果已经很好（claw-code 选择的正是 token overlap）。\n在我的 demo 里，这段被实现为十几行：\n1 2 3 4 5 6 7 8 9 10 def route_prompt(prompt: str, limit: int = 5) -\u0026gt; list[RoutedMatch]: p = set(t.lower() for t in _TOK.findall(prompt)) out: list[RoutedMatch] = [] for name, desc in TOOLS.items(): d = set(t.lower() for t in _TOK.findall(name + \u0026#34; \u0026#34; + desc)) s = len(p \u0026amp; d) if s \u0026gt; 0: out.append(RoutedMatch(\u0026#34;tool\u0026#34;, name, s)) out.sort(key=lambda m: -m.score) return out[:limit] 跑一下 route_prompt(\u0026quot;please add 3 and 4\u0026quot;)，输出 [RoutedMatch(kind='tool', name='add', score=1)]。在真实系统里，这个分数还会叠加别名表、模糊匹配、用户历史偏好，但骨架就是这么简单。\nInsight：先剪枝，再让模型做选择题。Agent 的瓶颈从来不是\u0026quot;模型不够聪明\u0026quot;，而是\u0026quot;每次把所有上下文都喂给模型\u0026quot;的暴力解。Routing 层的存在，是工业级 agent 对 prompt cache 和 token budget 做出的工程妥协——也是 Claude Code 能够注册海量内置 command 却不拖慢每次响应的根本原因。\n四、多入口共享同一 Runtime 这一节几乎不涉及算法，但它是 Claude Code 这类产品能同时活在 terminal、IDE 扩展、MCP server、SDK 四种形态里的架构底座。\n按 PDF §2.2 的描述，claw-code 把入口放在 src/entrypoints/ 下：\ncli.tsx——交互式 REPL。 init.ts——claw init 首次配置。 mcp.ts——Model Context Protocol server 模式。 sdk/——给第三方程序调用的库。 这 4 个入口共享同一个 agent runtime。也就是说，无论你是在终端敲命令、在 VS Code 插件里触发、还是另一个 agent 通过 MCP 调用过来，底层跑的都是同一份 QueryEngine.submit_message()。\nclaw-code 的 Rust 版本把这件事推得更彻底。它拆成两个 crate：\nrust/crates/rusty-claude-cli——专管终端 UI / 参数解析 / TTY 管理。 rust/crates/runtime——纯粹的 agent runtime，不知道自己被谁调用。 为什么这种解耦值得专门讲？因为绝大多数 agent demo 都把 runtime 和 CLI 缝在一起——main() 里直接 input() 然后 print()，听起来无害，但意味着：\n要做 MCP server？整个 runtime 重新抠一遍。 要做 SDK？再抠一遍。 要做 IDE 插件？再抠一遍。 三份代码分叉之后，哪份出 bug 你都说不清。 工程价值在于：CLI / MCP server / SDK 三种身份共享 80% 代码，bug 修一次处处生效，监控指标也是同一套。这不是架构洁癖，是上线一年后才会体会到的\u0026quot;还好当初没偷懒\u0026quot;。\nInsight：判断一个 agent 框架是 demo 还是产品，看一个信号就够——runtime 层是否显式禁止了对 stdin/stdout 的直接依赖。如果 runtime 里还出现 print、input、sys.stdout.write，它就没法进化成多入口系统。\n五、Bootstrap 是图，不是脚本 最后看 claw-code 里一个名字很朴素的文件：src/bootstrap_graph.py。它把启动过程拆成 7 个阶段：\nprefetch——预拉取模型配置、最近会话快照。 warnings——打印弃用信息、版本检查。 CLI parsing——解析命令行参数。 parallel setup——并行初始化 Tool registry、Permission policy、MCP client。 deferred init——可延迟的模块（遥测、自动更新检查）懒加载。 mode routing——根据入口类型（cli / mcp / sdk）分流。 query engine——最后拉起 QueryEngine。 乍一看这就是个顺序启动脚本，为什么要叫 \u0026ldquo;graph\u0026rdquo;？因为阶段之间有依赖关系，而且有些边是可跳过的。典型场景是 session resume：\n用户上次跑到一半 Ctrl-C 了，现在 claw --resume \u0026lt;id\u0026gt;。 prefetch 阶段发现本地已有快照 → 跳过重新拉取配置。 CLI parsing 阶段已被 resume 指令消化 → 跳过默认参数注入。 permission policy 直接从快照恢复 → 跳过重新询问用户授权。 如果 bootstrap 是线性脚本，上面每一条都得写 if resume: ... else: ...，逻辑很快变成意面。把启动过程建模成图（DAG）之后，resume 不过是\u0026quot;把部分节点标记为已完成\u0026quot;——图执行器会自动跳过前驱已满足的节点。\n这个设计的隐藏好处是启动时间可观测。每个节点都有独立的 span，线上如果发现冷启动变慢，立刻能看到是哪个节点回归了。对比一个巨型 main() 函数，光是找回归点就得打几十个 print。\nInsight：Bootstrap 用图建模，本质是承认\u0026quot;启动\u0026quot;本身也是一种可暂停、可恢复、可并行的工作流——和 agent loop 在哲学上完全一致。当你发现一个框架的作者在启动路径上就舍得用 DAG，那它的核心循环大概率也是可控的。\n回到那张图 现在回头看开篇的 Mermaid 图，你应该能把每一条边对应到源码了：\nU → R 走的是 route_prompt()，对应第三节的 token-overlap 剪枝。 R → Q 把 matched_* 作为参数送进 QueryEngine.submit_message()，对应第二节 Turn 的输入协议。 Q --\u0026gt;|loop| LLM --\u0026gt; TX --\u0026gt; Q 是第一节的 for turn in range(max_turns) 主干。 Q -. \u0026gt;max_turns .-\u0026gt; End / \u0026gt;budget 是三种 stop_reason 里的两种。 Q -. compact_after .-\u0026gt; C --\u0026gt; Q 是文章 3 会详细展开的 context compaction——用 compact_after_turns=12 触发，在不丢语义的前提下把 transcript 压缩回预算内。 把这张图记在心里，后续五篇文章基本都是在它的某一个节点上钻下去。\n思考题 claw-code 的默认 max_turns=8。不是 5，也不是 100。为什么？（提示：想想绝大多数真实编程任务的\u0026quot;tool call 深度\u0026quot;分布——读文件 / 编辑 / 运行测试 / 再修一轮——这个数字其实是 Anthropic 工程师从生产数据里反算出来的一个 p95 上界。超过 8 轮还没收敛的任务，一般意味着需要人工介入，而不是让模型继续烧钱。）\n下一篇预告 本篇聚焦的是\u0026quot;主循环\u0026quot;这一根脊椎。下一篇 《Tool 系统与权限模型》 会把上文一再略过的 permission_denials 打开——claw-code 如何把 200+ 内置工具塞进一套统一的 schema 注册表？PermissionDenial 这个对象的生命周期是怎样的？为什么 Bash 工具单独有 sandbox，而 Read 没有？工业级 agent 的\u0026quot;安全边界\u0026quot;究竟长什么样——我们下篇见。\n参考 claw-code 主仓核心路径：src/query_engine.py、src/runtime.py、src/bootstrap_graph.py、src/entrypoints/。 Rust 版：rust/crates/rusty-claude-cli、rust/crates/runtime。 claw-code 架构总览：https://claw-code.codes/architecture 本文配套 demo：code/01-industrial-agentic-workflow/mini_agent_loop.py（130 行 Python 标准库，无外部依赖）。 ","permalink":"https://mzf666.github.io/cli-agent/zh/posts/01-industrial-agentic-workflow/","summary":"Agent Loop、Turn 边界、控制流——一个真实可运行的 Agentic 框架到底比 while-loop demo 多了什么。","title":"工业级 Agentic Workflow：从 toy demo 到 Claude Code"},{"content":"引子：tool 调用到底是什么 如果你只读过教程级别的 agent 框架，\u0026ldquo;tool call\u0026quot;在你脑子里大概是这样的：\n1 result = registry[name](**args) 一行代码，函数指针加参数解包。但工业级 agent——这里以 claw-code（Claude Code 的开源对位实现）为参照——把这一行扩成了 14 步 pipeline：JSONSchema 校验、speculative bash 分类、3 类 hook、5 级权限模式、Linux namespaces 沙箱，外加结构化错误回灌。每一步都不是装饰，而是有事故复盘支撑的工程决策。\n为什么这么重？因为 tool 调用是 agent 唯一的外部世界接口。模型可以 hallucinate 一段废话，那是文本污染；模型 hallucinate 一次 rm -rf / 并被无脑执行，那是真实损失。本文沿着 claw-code 的源码——src/tools.py、rust/crates/runtime/src/permissions.rs、hooks.rs、sandbox.rs——把这条 pipeline 拆给你看。\n文末有一个 \u0026lt;150 LOC 的 stdlib-only Python demo（code/02-tool-and-permission/tool_pipeline.py），把 pipeline 的 5 个核心环节复刻一遍。\n1. Tool Registry：工具池是被裁剪的，不是堆出来的 引子 新人写 agent 框架时，registry 通常是个 dict[str, Callable]，越长越自豪。但 claw-code 的入口长这样：\n1 2 3 4 5 def get_tools( simple_mode: bool = False, include_mcp: bool = True, permission_context: ToolPermissionContext | None = None, ) -\u0026gt; tuple[PortingModule, ...]: 注意三个参数：simple_mode、include_mcp、permission_context。registry 不是一个常量，它是一个被运行时上下文裁剪过的视图。\n数据点 simple_mode=True 时，可见工具收缩到 3 个：BashTool、FileReadTool、FileEditTool。 默认完整集合在内部架构里 cap 在 15 个左右：FileRead, FileEdit, FileWrite, Bash, Glob, Grep, TodoWrite, TaskCreate, AskUserQuestion, Skill, Agent, MCPTool, Sleep 等。 真正的\u0026quot;全集\u0026quot;通过 load_tool_snapshot() 从 reference_data/tools_snapshot.json 加载，而不是硬编码。 permission_context 还会进一步剔除当前模式下显然不可用的工具（例如 ReadOnly 模式直接拒发 FileWrite）。 Insight 工具数量不是能力指标，而是 context cost line item。每多一个 tool，system prompt 就多一段 schema、多几十个 token。模型的\u0026quot;工具选择准确率\u0026quot;也随工具数下降——这是经验性结论，几乎所有做大规模 tool use 评测的团队都验证过。一个常被忽略的现象是：当工具集超过 30 个，模型开始出现\u0026quot;工具混淆\u0026rdquo;——把 Glob 用成 Grep、把 FileEdit 用成 FileWrite。这不是模型变笨，是 schema 在 attention 上互相挤兑。\n所以 claw-code 的设计哲学是：让工具池在每轮请求里都尽可能小。simple_mode 是给 sub-agent 用的，permission_context 是给受限会话用的，MCP 工具默认 lazy-load（见下一节）。这是一个工程师友好的取舍——你拿到的不是\u0026quot;全功能 IDE\u0026quot;，而是\u0026quot;按需加载的 IDE\u0026quot;。\n另一个值得抄的细节是：get_tools() 返回的不是 list 而是 tuple。这对 Python runtime 几乎没差，但对调用方传递的语义清晰——工具集合在一次请求内不可变。后续任何修改（比如 hook 想动态隐藏工具）必须显式生成新视图。这是把\u0026quot;不可变快照 + 显式重建\u0026quot;做成 API 习惯的小手势。\n2. JSONSchema + Deferred Tools：context 经济学 引子 每次模型 emit 一个 tool_use block，runtime 起手做的第一件事不是\u0026quot;调用\u0026quot;，而是 schema 校验：\n用 Zod schema 解析 tool_use.input 是否符合声明的 JSON Schema； 调用 tool 自带的 validateInput() 做语义校验（例如 path 是否在 workspace 内）。 任一步失败，pipeline 立刻短路，回灌一个 tengu_tool_use_error 给模型，并在 trace 里打 tool_use_error 事件。模型下一轮看到结构化错误，会自我纠偏。\n这是个朴素但容易被略过的细节：没有校验层的 agent，等于把 LLM 的语法错当成自己的责任去 catch。\nDeferred tools：把 schema 留在外面 更精彩的设计在这里。如果你看 claw-code 一次会话的 system prompt，会发现一段：\nSome tools are deferred and not listed above. When a deferred tool is surfaced later in the conversation, its full schema appears as a \u0026lt;function\u0026gt;{...}\u0026lt;/function\u0026gt; definition inside a \u0026lt;functions\u0026gt; block. 具体表现是：deferred tool 的名字出现在 \u0026lt;system-reminder\u0026gt; 里，但完整 JSON Schema 不进 system prompt。模型必须先调用 ToolSearch(\u0026quot;select:WebFetch\u0026quot;) 把 schema 拉进来，才能真正调用 WebFetch。\n为什么要这样？因为 MCP 生态里，一个企业用户接 10 个 server、每个 server 暴露 20 个 tool 是常态。200 个 tool 的 schema 全塞 system prompt，光这部分就能吃掉 30k+ token，且每轮都重复支付。Deferred tools = tool schema 的按需分页，把\u0026quot;这个 tool 存在\u0026quot;和\u0026quot;这个 tool 怎么调\u0026quot;解耦。\nInsight 工程师视角的 takeaway：schema 是 context，不是免费的元数据。如果你在做自己的 agent 框架，请把\u0026quot;tool 注册\u0026quot;和\u0026quot;tool schema 注入 prompt\u0026quot;显式分开。前者可以海量，后者必须吝啬。\ndeferred tools 还有一个隐性收益：audit 友好。当一个 tool 必须经过 ToolSearch 才能被调用，trace 里就天然有\u0026quot;模型在某轮请求 schema、下一轮才使用\u0026quot;的两段记录。安全审计想问\u0026quot;模型为什么用了 X tool\u0026quot;，不再只能看 system prompt 推断，而是有显式的\u0026quot;申请-发放\u0026quot;事件可查。这是 lazy loading 顺手送的可观测性。\n校验链上还有个容易踩的坑：Zod schema parse 和 validateInput() 不是冗余。前者管 JSON 结构（\u0026quot;path 是不是 string\u0026quot;），后者管语义（\u0026quot;path 是不是在 workspace 内\u0026quot;、\u0026quot;command 是不是空串\u0026quot;）。把两者合并到一个大 validator 看似简洁，但会让\u0026quot;结构错误\u0026quot;和\u0026quot;策略错误\u0026quot;在错误回灌时混淆——模型分不清\u0026quot;我写错了 JSON\u0026quot;还是\u0026quot;我请求了不该请求的资源\u0026quot;，纠偏方向就乱了。分开两层，错误码不同，模型才能精准修。\n3. 权限模型：5 级 + 3 规则类，评估顺序是 Deny → Allow → Ask 引子 权限是 agent 安全里最容易做歪的一环。常见的错法是\u0026quot;一个开关\u0026quot;——要么允许所有，要么 prompt 所有。claw-code 的做法是 5 级模式 × 3 类规则的笛卡尔积，源码在 rust/crates/runtime/src/permissions.rs：\n1 2 3 4 5 6 7 8 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum PermissionMode { ReadOnly, WorkspaceWrite, DangerFullAccess, Prompt, Allow, } 5 级模式从严到宽：ReadOnly（只读）→ WorkspaceWrite（限定 workspace 写）→ Prompt（每次问）→ Allow（默认允许）→ DangerFullAccess（裸奔）。\n3 类规则与评估顺序 每个模式下还可以叠加规则。规则结构是这样的：\n1 2 3 4 5 6 7 8 9 10 11 struct PermissionRule { raw: String, tool_name: String, matcher: PermissionRuleMatcher, } enum PermissionRuleMatcher { Any, Exact(String), Prefix(String), } 规则分 Deny / Allow / Ask 三类，评估顺序固定：\nDeny rules first. 命中即拒绝，不可推翻。 Allow rules. 命中则放行。 Ask rules. 即使在 Allow 模式下，命中 Ask 规则也强制弹窗——这条最关键，意味着\u0026quot;高风险操作可以从宽松模式里被钉出来\u0026quot;。 主语提取也很务实：从 tool_use.input 里抽 command（对 Bash）或 path（对 File*）字段，再跟 matcher 对。matcher 支持 Exact(\u0026quot;git status\u0026quot;)、Prefix(\u0026quot;git \u0026quot;)、Any 三种。\nInsight 把权限做成\u0026quot;模式 × 规则\u0026quot;的两维矩阵，本质上是承认：安全策略不是单调的偏序。你既需要\u0026quot;宽松默认 + 个别加锁\u0026quot;，也需要\u0026quot;严格默认 + 个别开洞\u0026quot;。Deny→Allow→Ask 的顺序保证了管理员的 deny 规则永远胜过用户的 allow，而 Ask 又能在最宽松的模式下保留人类介入点。\n值得一提的是 PermissionPrompter trait 把\u0026quot;决策\u0026quot;和\u0026quot;询问\u0026quot;解耦：\n1 2 3 pub trait PermissionPrompter { fn decide(\u0026amp;mut self, request: \u0026amp;PermissionRequest) -\u0026gt; PermissionPromptDecision; } CI 环境可以注入一个永远 Deny 的 prompter，IDE 环境可以注入一个真正弹 UI 的实现——同一套策略层，不同的承载终端。\n还有一个工程上很关键但容易被忽略的点：规则的主语提取是 tool-aware 的。Bash 工具的主语是 command 第一个 token（git、curl、rm），File* 工具的主语是 path（且会先 normalize）。如果你天真地用 command 字段对所有 tool 做 prefix match，规则就只能写给 Bash 看；反过来，把 path 当字符串硬匹也会因为 ./foo vs foo 这种归一化差异翻车。claw-code 在 permissions.rs 里把\u0026quot;如何从 input 提主语\u0026quot;做成 per-tool 的小函数，这是把\u0026quot;匹配规则\u0026quot;和\u0026quot;工具语义\u0026quot;耦合恰到好处的写法——既不强迫规则作者懂全部工具内部，又不让规则系统对所有工具一视同仁。\n4. Sandbox = Linux Namespaces：用内核原语做隔离 引子 很多人以为 agent 沙箱 = Docker。claw-code 的选择更轻：直接用 unshare 起一组 Linux namespaces，源码在 sandbox.rs：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 let mut args = vec![ \u0026#34;--user\u0026#34;.to_string(), \u0026#34;--map-root-user\u0026#34;.to_string(), \u0026#34;--mount\u0026#34;.to_string(), \u0026#34;--ipc\u0026#34;.to_string(), \u0026#34;--pid\u0026#34;.to_string(), \u0026#34;--uts\u0026#34;.to_string(), \u0026#34;--fork\u0026#34;.to_string(), ]; if status.network_active { args.push(\u0026#34;--net\u0026#34;.to_string()); } args.push(\u0026#34;sh\u0026#34;.to_string()); args.push(\u0026#34;-lc\u0026#34;.to_string()); args.push(command.to_string()); User / mount / IPC / PID / UTS namespace 默认全开，network namespace 按需。没有 seccomp、没有 chroot、没有 Docker。\n三档文件系统隔离 1 2 3 4 5 6 pub enum FilesystemIsolationMode { Off, #[default] WorkspaceOnly, AllowList, } 默认 WorkspaceOnly：mount namespace 内只 bind-mount 当前 workspace 为 rw，其余 fs 路径视情况隐藏或只读。AllowList 让你显式追加可写路径，例如 ~/.cache/pip。\nCI 友好的探测 启动前还有一段防御：\n1 2 3 4 5 6 fn unshare_user_namespace_works() -\u0026gt; bool { // ... std::process::Command::new(\u0026#34;unshare\u0026#34;) .args([\u0026#34;--user\u0026#34;, \u0026#34;--map-root-user\u0026#34;, \u0026#34;true\u0026#34;]) .status().map(|s| s.success()).unwrap_or(false) } 很多 CI（包括 GitHub Actions 的某些 runner）出于安全把 user namespace 禁了。这个探测函数用 OnceLock 缓存结果，sandbox 在启用前先 probe，否则 fallback 成无沙箱模式并打 warning。\nmacOS 上则没有对应实现——sandbox 仅 Linux。这也是个诚实的工程取舍：与其做半残的 macOS sandbox-exec 集成，不如告诉用户\u0026quot;在 Linux 容器里跑 agent\u0026quot;。\nInsight namespaces 的好处是启动开销近零（毫秒级）、不需要镜像、不需要 daemon。坏处是不能阻止 syscall 滥用（没 seccomp）。claw-code 的判断是：tool pipeline 上游已经有 schema 校验 + permission 规则把 90% 的恶意意图筛掉，sandbox 是\u0026quot;最后一道防线\u0026quot;，目标是控制 blast radius而不是\u0026quot;防御任意攻击者\u0026quot;。这个定位决定了它选择轻量原语而不是重型容器。\n这里的设计哲学和\u0026quot;纵深防御\u0026quot;完全一致：每一层都不要求自己能挡住所有攻击，但要求自己挡住一类典型攻击。schema 挡格式畸形，permission 挡显式恶意，sandbox 挡误伤型 blast。重型容器（Docker/Firecracker）能挡更多，但代价是启动时间从毫秒变秒、磁盘从零变 GB——对一个一分钟内可能跑几十次 tool 的交互式 agent 来说，性价比不划算。\n5. Hooks：不是观察者，是策略层 引子 Hook 在大多数框架里是\u0026quot;日志钩子\u0026quot;。在 claw-code 里，它是第一公民的策略层。源码 hooks.rs 定义了三类事件：\n1 2 3 4 5 pub enum HookEvent { PreToolUse, PostToolUse, PostToolUseFailure, } 但真正的精妙在 hook 的输出 schema：\n1 2 3 4 5 6 7 struct ParsedHookOutput { messages: Vec\u0026lt;String\u0026gt;, deny: bool, permission_override: Option\u0026lt;PermissionOverride\u0026gt;, permission_reason: Option\u0026lt;String\u0026gt;, updated_input: Option\u0026lt;String\u0026gt;, } 对应的 JSON 字段：\nsystemMessage / reason → 注入下一轮的 system context； continue: false 或 decision: \u0026quot;block\u0026quot; → 直接 deny； hookSpecificOutput.permissionDecision → 改写本次权限决策（Allow / Deny / Ask）； hookSpecificOutput.permissionDecisionReason → 解释给模型听； hookSpecificOutput.updatedInput → 重写 tool 的输入 JSON。 14 步 pipeline 全貌 把上面四节拼起来，一次 tool 调用的完整 pipeline 是这样的（PDF §8 的简化版）：\nflowchart TD M[Model emits tool_use] --\u0026gt; V[Zod validate + validateInput] V --\u0026gt;|fail| Err[tool_result error] V --\u0026gt; Pre[PreToolUse hooks] Pre --\u0026gt;|deny| Err Pre --\u0026gt;|updatedInput| Perm Pre --\u0026gt; Perm[Permission policy: Deny→Allow→Ask rules] Perm --\u0026gt;|ask| User[(prompter)] Perm --\u0026gt; SB[Sandbox: unshare namespaces] SB --\u0026gt; Call[tool.call] Call --\u0026gt; Post[PostToolUse hooks] Call --\u0026gt;|throw| Fail[PostToolUseFailure hooks] Post --\u0026gt; R[tool_result back to model] 完整 14 步还包括：find tool by name → MCP metadata 拼装 → schema parse → validateInput → speculative bash classifier（专门判断 Bash 命令的副作用类别）→ PreToolUse → permission decide → updatedInput rewrite → sandbox wrap → tool.call → analytics → PostToolUse → structured output 回灌 → 失败时 PostToolUseFailure。\nInsight 一个 hook API，五种能力：deny / 改 permission / 改 input / 注入 context / 终止整轮。这是\u0026quot;小 surface 大 power\u0026quot;的范例。对比一下：如果你只允许 hook 返回 bool（拦或不拦），那么\u0026quot;想把 rm -rf / 改成 rm -rf ./build\u0026ldquo;这种场景就只能在 tool 内部硬编码。给 hook 一个结构化输出 schema，等于把策略和工具实现彻底解耦。\n工程上还有个隐藏好处：hook 可以由用户脚本（甚至是另一个进程）实现，输出 JSON 即可。这意味着安全团队能写自己的 hook 二进制，不用改 agent 主代码。\n代码 demo：把 pipeline 缩到 150 行 完整代码在 code/02-tool-and-permission/tool_pipeline.py，stdlib only，无外部依赖。核心 5 步：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 def execute(self, tool_use: dict) -\u0026gt; dict: tool = self.registry.get(tool_use[\u0026#34;name\u0026#34;]) # 1. validate err = validate(tool.input_schema, tool_use[\u0026#34;input\u0026#34;]) if err: return error(err) # 2. pre_hook (can rewrite input or deny) pre = self.pre_hook(tool, tool_use[\u0026#34;input\u0026#34;]) if pre.deny: return error(pre.reason) tool_input = pre.updated_input or tool_use[\u0026#34;input\u0026#34;] # 3. permission decision = self.policy.decide(tool, tool_input) if decision is Decision.DENY: return error(\u0026#34;denied by policy\u0026#34;) # 4. call result = tool.fn(**tool_input) # 5. post_hook self.post_hook(tool, tool_input, result) return ok(result) Demo 注册了两个 tool：\nread_file：安全工具，schema 要求 path: str； shell：危险工具，pre_hook 检测到 rm -rf / 会把 command 重写成 echo '[blocked rm -rf]'，并在 audit log 里记一条\u0026quot;updatedInput\u0026rdquo;。 跑 python tool_pipeline.py 你会看到 4 个场景的输出：合法 read、schema 错误、被 hook 改写的 rm -rf、被 deny rule 拦掉的 curl evil.com。每一步都打印谁拦了 / 谁改了什么，一眼能看到 pipeline 的\u0026quot;切片\u0026quot;。\n收束 把这一节的几个隐喻凑起来：\nTool 是 agent 的手——你必须先承认它要碰真实世界。 Schema 是手套——校验输入、拦截畸形调用，是最便宜也最高 ROI 的一道。 Permission 是合同——5 级模式 × 3 类规则，给安全团队一个可表达策略的语言。 Sandbox 是房间——namespaces 控制 blast radius，承认有限但保住关键防线。 Hook 是策略层——它能 deny、能改、能注入 context，把\u0026quot;工具实现\u0026quot;和\u0026quot;安全策略\u0026quot;解耦。 这五者合起来，就是 claw-code 的 14 步 tool pipeline。每一步都不是装饰，每一步都对应一类生产事故。\n下一篇预告 当一个 agent 跑不动复杂任务，怎么并行调度多个 sub-agent 而不污染主 context？claw-code 的答案是 Subagent + Plan + Worktree 三件套：subagent 拿到隔离的工具池和上下文，plan 模式让模型先写方案再执行，worktree 让多个 agent 在 git 层物理隔离。下一篇我们拆这条流水线——以及它如何回避\u0026quot;多 agent 系统\u0026quot;最常见的两个坑：context 串味和写冲突。\n引用 src/tools.py rust/crates/runtime/src/permissions.rs rust/crates/runtime/src/hooks.rs rust/crates/runtime/src/sandbox.rs claw-code internal architecture PDF §3.4（tool 集合）、§8（toolExecution pipeline） ","permalink":"https://mzf666.github.io/cli-agent/zh/posts/02-tool-and-permission/","summary":"Tool registry、JSONSchema 校验、deferred tools 懒加载、permission/sandbox/hooks——把工具调用做成可审计的边界。","title":"Tool 系统与权限模型：让 Agent 安全地动手"},{"content":"0. 引子：为什么单 agent 会撞墙 前两篇我们看到的是单个 agent 的「主循环 + tool 边界」。这套结构有一个天然天花板：单个 turn 的能力上限 = context window × tool budget。当任务变成「读 30 个文件、产出一个 PR、再跑一遍验证」这种多步骤工程任务时，单 agent 会撞到三堵墙：\n上下文爆炸：把 30 个文件的全文都塞进主对话，下一轮 prompt cache 命中率瞬间塌陷。 角色漂移：让一个 agent 既写代码又审代码，最后它会站在「我刚写的」立场为自己辩护。 并行写冲突：两个分支同时改 package.json，谁也不让谁。 把任务派给 subagent 听起来是显然的解法，但**「让 agent 调 agent」并不是简单地多开一个会话**。它牵涉到 cache 经济、git worktree 锁、role 限权、生命周期管理。本文以 claw-code 为参照，拆解它在工程上是如何落地的——核心文件位于 src/tools/AgentTool/{AgentTool.tsx, runAgent.ts, resumeAgent.ts, forkSubagent.ts, agentMemory.ts, builtInAgents.ts}，Rust runtime 侧的并行调度落在 rust/crates/runtime/src/{task_registry.rs, task_packet.rs, branch_lock.rs}。\n1. 内置 Agent 是「角色」，不是 worker 引子 很多框架对 subagent 的抽象是「worker pool」：派一个任务、回收结果、生命周期到此结束。claw-code 不是这样。它的 6 个内置 agent 是有人格、有职责边界的角色，每个角色的 system prompt 都写死了「你能干什么、不能干什么、必须输出什么」。\n6 个内置 agent 参见 src/tools/AgentTool/builtInAgents.ts：\n角色 职责 工具集 必须输出 General Purpose 通用研究 / 多步任务 全量 自由格式 Explore read-only 信息收集 Read/Grep/Glob + Bash 白名单（ls / git status / git log / git diff / find / grep / cat / head / tail） 文件清单 + 摘要 Plan 架构师，写实现路线 read-only 必含「Critical Files for Implementation」段 Verification 对抗式审计 read-only + 受控 Bash VERDICT: PASS | FAIL | PARTIAL + 命令 + 观察输出 Claude Code Guide 文档/帮助 文档检索 引用原文 Statusline Setup 配置专用 配置文件读写 diff Insight：Verification 的「对抗设计」 这是整个 subagent 体系最有想法的一处。Verification agent 的 system prompt 直白写着 \u0026ldquo;your job is to try to break it\u0026rdquo;——它不是「确认实现是否正确」，而是「找出实现错在哪里」。再加上强制输出格式 VERDICT: PASS|FAIL|PARTIAL + 触发命令 + 观察输出，主 agent 拿到的就不是一段含混的「看起来 OK」，而是一个可机读、可 grep、可触发后续 retry 的结构化判决。\n为什么这是 standout insight？因为它解决了 LLM-as-judge 的根本病灶：自检会自我合理化。把「写」和「验」交给同一个 agent，等于让作者审稿自己——它会本能地为自己的代码找补理由。把 Verification 拆成独立角色、独立 context、独立的「敌意 prompt」，等于强制做了一次 evaluator-generator 解耦。这一招在 self-improving 工作流里几乎是必备的。\n每个角色的 system prompt 都设了硬性 role cap：plan agent 不能写代码（工具集里就没 Edit/Write），verify agent 不能改实现（即便它发现 bug 也只能报告），explore agent 连 git commit 都执行不了。权限不是 runtime 检查，而是 spawn 时就编译进去的。\n2. Fork 路径 vs Normal 路径：cache 经济学 引子 如果 subagent 是「新开一个 conversation」，那么每次派 subagent 的成本 = 重建一份完整 system prompt + claudeMd + git status + 工具定义 = 数千 token 的 cache miss。在 Anthropic API 里，cache write 是 1.25× input price，cache read 是 0.1×。一次失误的上下文重建 = 12.5 倍 hit 成本。claw-code 在这里给了一个非常工程化的双路径方案。\n双路径 参见 src/tools/AgentTool/{forkSubagent.ts, runAgent.ts}：\n维度 Fork 路径 Normal 路径 触发 subagent_type 省略 + fork 启用 显式 subagent_type: \u0026quot;explore\u0026quot; 等 system prompt 继承 parent 完整 从 agentDefinition 重建 slim 版 工具集 useExactTools = true，与父一致 按角色裁剪 历史 messages 拷贝 parent.messages 空 API 前缀 字节级一致 → cache 命中 重建 → cache miss Insight：Fork 不是进程概念，是 cache 经济学 Unix 程序员看到 fork 第一反应是「进程隔离」。在这里它不是为了隔离，而是为了「让发给 API 的请求前缀和父 turn 完全一致」，从而触发 prompt cache。隔离是 normal 路径的活，fork 路径反而是主动反隔离——保留全部上下文，只是把后续的 turn 划成 sidechain，不让 sidechain 的输出回污父 transcript。\n这个设计意味着：「派一个 subagent 帮我做点小事」的成本，可以低到只有几十个新 token 的 cache write。多数开源框架在这一步默认走 normal 路径，每次 subagent 调用都是几千 token 的全量重建——静默 2× 甚至 5× 的 token 成本，但用户在 dashboard 上只看到「token 花得有点多」，不会意识到根因是 cache 策略。\n何时该用哪条路径？经验法则：\nFork：任务高度依赖父上下文（\u0026ldquo;based on what we just discussed, also check X\u0026rdquo;）。 Normal：任务有明确角色（plan/verify），需要强 role cap，或父上下文已经很大、不希望 subagent 也背着这堆历史。 3. Foreground vs Background：四种生命周期 引子 subagent 派出去之后，主 agent 该等它回来，还是该接着做别的事？claw-code 在 AgentTool.call() 里给了 4 种分支。\n4 种分支 源自 src/tools/AgentTool/AgentTool.tsx：\nforeground sync：阻塞主 turn，主 agent 在 await 上等结果。适合「下一步必须依赖结果」的场景，比如先 plan 再 implement。 background async：subagent 拿到自己的 AbortController，主 turn 立刻继续，subagent 完成时通过 notification 回调。适合「可以并行跑、结果之后再看」的探索任务。 remote-launched：从 IDE / API 触发，主 agent 不在场。 teammate-spawned：在 multi-agent 协作里，由另一个 subagent 派生。 Insight：Background 的 outputFile 与「不许偷看」 Background agent 的输出会落到一个 outputFile，主 agent 看得到这个路径。但是——主 prompt 里明确不鼓励主 agent 在 subagent 完成前去 cat 这个文件。\n这条约束读起来像是道德说教，工程上其实是为了避免 race condition 和「半成品中毒」：subagent 还在写文件时，主 agent 偷看一眼，把残缺输出当成结论塞回主上下文，整个对话就跑偏了。把\u0026quot;不要看\u0026quot;写进 prompt 而不是写进权限系统，是因为这是一种协作礼仪而非安全边界——主 agent 完全有权限读那个文件，只是不应该。\n4. runAgent() 是 runtime 构造器 引子 subagent 不是 new Agent(prompt) 这么简单。每次 spawn 都要走一遍完整的 runtime 装配流水线——这是 claw-code 把 subagent 做得「像独立 process」的关键。\n装配流水线 src/tools/AgentTool/runAgent.ts 大致顺序：\ninit agent-specific MCP servers → 过滤/克隆 ToolUseContext（深拷贝，防 mutation） → file state cache（继承父的 read 状态，避免重读） → 给 read-only agent 瘦身 claudeMd / gitStatus → 解析 permission mode（plan / acceptEdits / etc.） → 计算 resolved tools（角色白名单 ∩ 启用工具） → 拼装 system prompt（agentDefinition + 上下文片段） → AbortController.create() → 触发 SubagentStart hooks → frontmatter hooks / skills 加载 → merge MCP tools 到 toolset → 构造 ToolUseContext → query() 主循环 → 记录 sidechain transcript → cleanup（MCP / hooks / perfetto / todo / bash session） Insight：「瘦身」是上下文卫生 注意这一步：给 read-only agent 做 claudeMd 和 gitStatus 的瘦身。一个 explore agent 不需要知道完整的 contributing guide、不需要 50 行的 git status——主 agent 用得着，subagent 用不着。瘦身能直接砍掉每次 subagent 调用几百到几千 token 的固定开销。\ncleanup 阶段同样关键：MCP server、hooks、perfetto trace、todo state、bash session 都要单独回收。把 subagent 写成「装了又拆」的临时 process，避免长跑后状态泄漏。\n5. Worktree + Task Packet：并行写不冲突 引子 读 30 个文件可以并行——只读没有冲突。但写呢？两个 subagent 同时在 main 分支上 git apply 各自的 patch，必崩。claw-code Rust runtime 的答案是 per-task git worktree + 单 mutex 的 TaskRegistry。\nTask / TaskRegistry rust/crates/runtime/src/task_registry.rs 的核心数据结构：\n1 2 3 4 5 6 7 8 9 10 11 12 13 pub struct Task { pub task_id: String, // \u0026#34;task_{ts}_{counter}\u0026#34; pub prompt: String, pub task_packet: TaskPacket, pub status: TaskStatus, pub messages: Vec\u0026lt;Message\u0026gt;, pub output: Option\u0026lt;String\u0026gt;, pub team_id: Option\u0026lt;String\u0026gt;, } pub enum TaskStatus { Created, Running, Stopped, Completed, Failed, } 状态机是单向的：Created → Running/Stopped → Completed/Failed。没有 reset、没有 reopen——一个 task 失败了就让它失败，retry 是开新 task。这避免了「half-completed task 被 resume 成 zombie」的复杂边界。\n整个 registry 用 Arc\u0026lt;Mutex\u0026lt;RegistryInner\u0026gt;\u0026gt; 包裹：\n1 2 3 pub struct TaskRegistry { inner: Arc\u0026lt;Mutex\u0026lt;RegistryInner\u0026gt;\u0026gt;, } 锁粒度粗——所有 spawn / status update / list 都过同一把锁。这看起来不优雅，但spawn 频率本来就低（subagent 数量是 O(10) 量级），用细粒度锁优化收益小、复杂度高，是典型的「先把对的写出来再说」的工程取舍。\nTaskPacket：意图的契约 rust/crates/runtime/src/task_packet.rs 定义的 TaskPacket 携带：\nobjective：这个 task 要达成什么。 scope：允许触碰的目录 / 文件 prefix。 repo metadata：仓库 root、当前 HEAD、worktree base。 branch policies：能否新建分支、命名规则。 acceptance tests：什么命令通过算成功。 escalation：失败/超时时升级给谁。 这是一个意图层的契约——主 agent 不是把\u0026quot;做这件事\u0026quot;扔给 subagent 就完了，而是把成功标准、可触碰的边界、升级路径全部写进 packet。subagent 跑偏了，runtime 可以根据 packet 强制中止。\nWorktree 锁 rust/crates/runtime/src/branch_lock.rs + stale_branch.rs 给每个 task 分配一个 git worktree（git worktree add），worktree 之间分支独立、文件系统独立。branch_lock 防止两个 task 抢同一个分支，stale_branch 定期清理已完成 task 留下的死 worktree。\n这是「并行 agent 改代码」的物理基础：进程级隔离的代价是开 git worktree，比开容器轻、比同目录互锁安全。\n6. 全流程总览 flowchart TB Main[Main agent turn] --\u0026gt;|AgentTool.call| D{subagent_type?} D --\u0026gt;|omitted+fork| Fork[Fork: inherit ctx \u0026amp; tools, cache-identical prefix] D --\u0026gt;|explicit| Norm[Normal: build agent-specific prompt+tools] Fork \u0026amp; Norm --\u0026gt; RA[runAgent: MCP, hooks, skills, perm mode] RA --\u0026gt; WT[(worktree / branch lock)] RA --\u0026gt; Q[query loop] Q --\u0026gt; R[Sidechain transcript + cleanup] R --\u0026gt; Main 把图和前面五节对照起来看，整条链路的设计意图就清楚了：\nD 节点承担 cache 经济学决策。 RA 节点做 runtime 装配 / 瘦身 / 权限解析。 WT 节点做物理隔离。 Q 节点是 subagent 自己的 turn loop（与文章 1 描述的主循环同构）。 R 节点做协议化清理：sidechain transcript 入库、所有动态资源回收。 7. Demo：60 行复刻 fork/normal + 并行调度 完整代码见 code/03-subagent-plan-worktree/subagent_demo.py。核心思路：\n1 2 3 4 5 6 7 8 9 10 11 12 13 def spawn_subagent(parent: \u0026#34;MainAgent\u0026#34;, prompt: str, role: str, fork: bool = False) -\u0026gt; Task: if fork: # cache-friendly: 继承父 messages + 全工具集 messages = list(parent.messages) tools = parent.tools sys_prompt = parent.system_prompt else: # role-capped: 重建 slim prompt + 角色工具白名单 messages = [] tools = ROLE_TOOLS[role] sys_prompt = ROLE_PROMPTS[role] task = registry.create(prompt=prompt, role=role, system=sys_prompt, tools=tools) return task Demo 主流程派出 2 个 explore + 1 个 plan + 1 个 verify，用 ThreadPoolExecutor 并发跑，最后从 verify 的输出里 grep 出 VERDICT: 行——把第 1 节里讲的「结构化判决」真的跑给你看。\n8. 收束：Workflow 三幕落幕 到这里，CLI Agent 的 Workflow 主线讲完了：\n文章 1——主循环：单 agent 一个 turn 怎么走。 文章 2——tool 边界：怎么让一个 turn 安全地动手。 文章 3（本篇）——并行扩展：怎么让多个 turn 在隔离中协作。 三篇连起来回答了同一个问题：怎么让一个 LLM 在工程系统里像一个进程一样可控地运行。但这条主线还缺一条腿——记忆。一个跑完就忘的 agent，每次 spawn 都要重新摸索；一个能把这次的 insight 沉淀下来、下次直接复用的 agent，才是真正能「越用越聪明」的系统。\n下一篇预告 文章 4《为什么需要 Memory System》将切入第二条主线，正式介绍 claw-code 的 Memory 三件套：短期 working memory（turn-local）、中期 session memory（cross-turn within session）、长期 persistent memory（cross-session, claudeMd / agent-memory / skill cache）。我们会先从「为什么不能只靠 context window」讲起，把 memory 的工程必要性建立起来，再在文章 5、6 里深入存储/检索结构与演化机制。\n引用 src/tools/AgentTool/AgentTool.tsx src/tools/AgentTool/runAgent.ts src/tools/AgentTool/resumeAgent.ts src/tools/AgentTool/forkSubagent.ts src/tools/AgentTool/agentMemory.ts src/tools/AgentTool/builtInAgents.ts rust/crates/runtime/src/task_registry.rs rust/crates/runtime/src/task_packet.rs rust/crates/runtime/src/branch_lock.rs claw-code internal PDF §5.4 / §6.3 / §6.5 / §6.6 / §10.2 ","permalink":"https://mzf666.github.io/cli-agent/zh/posts/03-subagent-plan-worktree/","summary":"上下文隔离的 subagent、计划模式、git worktree 隔离、并行调度——Agent 如何在不污染主上下文的前提下扩展能力。","title":"Subagent、Plan 与 Worktree：并行隔离的工程艺术"},{"content":"起：第 101 次还会犯同样的错 让一个 agent 跑满 100 个 session，第 101 次让它再做一遍上次踩过坑的事，它大概率还会再踩一次。 原因不在 LLM 不够聪明，而在所有有价值的信号都跟着 transcript 一起被 compact 掉了：第 30 个 turn 你纠正过一次\u0026quot;别用 emoji\u0026quot;，第 60 个 turn 你确认过 deploy 走 Vercel 不走 Cloudflare，到第 101 次新开 session，那个修正只剩一句被压缩成 \u0026lt;summary\u0026gt;previous discussion\u0026lt;/summary\u0026gt; 的残影，根本不可能影响下一次工具调用。\n工业界的解法分两派：\nembedding + 向量库：把 transcript 丢进一个 embedding 模型，存进 Pinecone / Chroma，查询时做向量召回。优点是写代码爽；缺点是黑盒、滞后、不可读、不能 git diff，调一个 chunk size 要重跑全库。 typed file-based memory：用普通的 markdown 文件，分类型放在固定路径，外加一个 MEMORY.md 索引。人能读、git 能 diff、迁移成本为零。这是 claw-code、Claude Code、Cursor 等一票工业级 CLI agent 的共同选择。 这一篇是 Memory 三部曲的开篇，回答最朴素的两个问题：为什么不是 RAG？为什么是类型化文件 + 索引？ 后面两篇会接着讲：第 5 篇讲存储 / 检索 / 失效，第 6 篇讲 memory 与 agent 的协同进化。\n关于 transcript compaction、compact_after_turns=12, keep_last=10 这些参数的细节，见本系列第 1 篇 Industrial Agentic Workflow。本文只关心 compaction 之外那部分应该被永久记住的信号。\n1. Memory ≠ Chat History 最常见的误会是把 memory 当成\u0026quot;更长的 chat history\u0026quot;。这是错的。Transcript 和 memory 在三个维度上是正交的：\n维度 Transcript（对话历史） Memory（记忆） 生命周期 单 session，会被 compact 跨 session，持久 写入方式 被动追加（每个 turn 自动塞入） 主动写入（agent 决策 / 用户授意） 读出方式 全量喂给 LLM（受窗口限制） 经过 discovery + ranking 的 top-K 子集 结构 线性、无类型 类型化、可索引 claw-code 在 system prompt 装配阶段把这两个 slot 显式分开。src/transcript.py 负责前者（滑窗 + compaction），memdir/ 负责后者。两者最后在 getMemoryPromptSection() 那一步汇合到 system prompt 的 dynamic suffix——也就是说：每个 turn 开始之前，memory 都会被重新读、重新选、重新拼一次，它不是一段死掉的历史，而是活的、当下相关的索引。\n这一区分极其重要：transcript 可以丢，memory 不能丢；transcript 是\u0026quot;上下文窗口里的临时缓存\u0026quot;，memory 是\u0026quot;agent 的长期人格\u0026quot;。\n2. 五类典型 Memory：从 user 到 team 打开 src/reference_data/subsystems/memdir.json，里面列出了 claw-code 的 8 个 TS 模块：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 { \u0026#34;archive_name\u0026#34;: \u0026#34;memdir\u0026#34;, \u0026#34;package_name\u0026#34;: \u0026#34;memdir\u0026#34;, \u0026#34;module_count\u0026#34;: 8, \u0026#34;sample_files\u0026#34;: [ \u0026#34;memdir/findRelevantMemories.ts\u0026#34;, \u0026#34;memdir/memdir.ts\u0026#34;, \u0026#34;memdir/memoryAge.ts\u0026#34;, \u0026#34;memdir/memoryScan.ts\u0026#34;, \u0026#34;memdir/memoryTypes.ts\u0026#34;, \u0026#34;memdir/paths.ts\u0026#34;, \u0026#34;memdir/teamMemPaths.ts\u0026#34;, \u0026#34;memdir/teamMemPrompts.ts\u0026#34; ] } memoryTypes.ts 把 memory 分成五类，每一类背后都是一种不同的信号衰减规律：\nuser — 用户的身份、偏好、角色契约。这次和这个用户合作，用什么语言、有没有什么忌讳、是 staff eng 还是 PM。这类信号几乎不衰减，永远应该在 prompt 里。 project — 当前项目的状态机：谁在做什么、为什么、deadline 是哪天、上一次提交跑了什么测试。这类信号生命周期等于项目本身，可能两周可能两年。 reference — 外部系统的指针：Linear 项目 ID、Grafana dashboard URL、Notion 页面、内部 wiki。本身不存内容，存\u0026quot;去哪查\u0026quot;。 feedback — 用户给过的 do/don\u0026rsquo;t。是双向的：既包括\u0026quot;以后别这么做\u0026quot;（修正），也包括\u0026quot;这次做得对，下次照做\u0026quot;（确认）。这类是 agent 自我进化最关键的燃料，第 6 篇会展开。 team — 团队级别的共识。claw-code 单独把这类拆出来用 teamMemPaths.ts 处理，因为路径解析逻辑不同（要去找 ~/.agent/team/\u0026lt;team\u0026gt;/），prompt 模板也不同（在 teamMemPrompts.ts 里）。 每一类对应不同的 path resolver、不同的 scan 策略、不同的 age policy。比如 feedback 类型在 memoryAge.ts 里通常被赋予较长 TTL，而 reference 类型一旦超过 30 天没被命中就降权——因为外部链接更容易 stale。\n简单 mental model：每一种 memory 类型 = 一种独立的衰减函数。把它们丢到一个 embedding 空间里再去 cosine 相似度，就是把这些信号全部抹平成一种东西，损失了最重要的结构。\n3. MEMORY.md 是索引，不是 blob 很多人第一次看到 MEMORY.md 这个名字会以为它是\u0026quot;把所有记忆装在一起的大文件\u0026quot;。完全相反。在 Claude Code / claw-code 里，MEMORY.md 是一份短得离谱的索引文件，它只列出\u0026quot;有哪些 memory 文件、每个一句话讲什么\u0026quot;。\n打开你眼前正在跑的这个 Claude Code session 的 MEMORY.md，它长这样：\n1 2 - [User profile](user_role.md) — Zhanfeng Mo, runs mzf666.github.io; building llm-infra \u0026amp; cli-agent technical sub-sites. - [cli-agent blog series](project_cli_agent_blog.md) — 6-part Chinese deep-dive on agentic workflow + memory system, claw-code as primary reference. 格式非常严格：- [Title](file.md) — one-line hook。每条不超过一行。 设计意图有两层：\n索引短 → 每 turn 廉价地热在 cache 里。MEMORY.md 本体常年只有几百字节，可以稳稳地塞进 system prompt 的可缓存区段。 entry 是 deferred load。索引里只放钩子（filename + 一句话 hook），LLM 在 reasoning 时如果觉得需要某条记忆的全文，再发一个 Read(user_role.md) 工具调用去取。绝大多数 turn 根本用不到全文，能省就省。 这是一种很 Unix 的设计：把\u0026quot;目录\u0026quot;和\u0026quot;文件内容\u0026quot;分开，目录便宜可以频繁扫，文件按需加载。它对应到 LLM 世界，就是把\u0026quot;我有哪些记忆\u0026quot;和\u0026quot;某条记忆具体说了什么\u0026quot;分成两个 token 预算池。\n4. Discovery Pipeline：4 步把 memory 注入 prompt 类型化只是 what，真正的工业 know-how 在 how——如何在每个 turn 廉价地选对那一小撮 memory。claw-code 的 memdir 包把这件事拆成 4 个串行阶段：\nflowchart LR subgraph Discovery P[paths: cwd→proj→user→team] --\u0026gt; S[memoryScan] S --\u0026gt; A[memoryAge: TTL, staleness] A --\u0026gt; F[findRelevantMemories: relevance score] end F --\u0026gt; Idx[MEMORY.md index] Idx --\u0026gt; SP[System prompt suffix slot] SP --\u0026gt; LLM[LLM] 每一步对应一个独立模块：\npaths.ts — 解析路径优先级链：cwd → project root → user home → team。后写的覆盖先写的；同一类 memory 在多层都有时，越靠近 cwd 的越具体、越优先。这个顺序与 git 的 .gitignore 解析、或者 shell 的 PATH 是同一种思想。 memoryScan.ts — 走目录列出候选 entry。这里会跳过 .git/、隐藏目录、二进制文件，只保留 markdown。 memoryAge.ts — 给每个 entry 算 recency 与 staleness：每类 memory 有自己的 TTL，超过 TTL 的 entry 会被降权或直接过滤。这一步避免了\u0026quot;三年前 reference 链接挤掉本周 feedback\u0026quot;。 findRelevantMemories.ts — 真正的 ranker：综合 token overlap、role match、type weight，给出 top-K。这里不用向量嵌入，用的是 plain old token overlap + 启发式权重。原因下文专门讲。 最终 top-K 被 render 成上面那种 - [Title](file.md) — hook 的索引行，注入 system prompt 的 dynamic suffix slot。整条链路里没有任何 ANN 索引、没有任何 embedding 模型调用，全部跑在本地、纯文件 IO、毫秒级。\n这里很多人会问：那相关性不就只能很粗吗？是的，而这正是它工作的原因——见下一节。\n5. Cache 经济学：为什么\u0026quot;粗\u0026quot;反而是优势 PDF §3.3 提到一个常被忽视的常量：SYSTEM_PROMPT_DYNAMIC_BOUNDARY。它把整个 system prompt 切成两段——\n静态 prefix：工具定义、行为约束、style guide。这些字节在整个 session 不变，命中 KV cache，零摊销成本。 动态 suffix：memory 索引、当前时间、cwd、git status。每个 turn 都可能变，变了就 cache miss。 把这条推论展开，就得到 memory 系统设计的核心约束：\n在单个 session 内部，memory 索引必须尽可能稳定。\n如果你每个 turn 都重新跑一次 vector search，每次返回的 top-3 顺序略有抖动，那 system prompt 的字节序列就会持续变化，整段 dynamic suffix 之后的 KV cache 全部作废。在一个 200 turn 的 session 里，这意味着累计几百万 token 的重复编码——成本可能翻倍。\n这就是为什么 claw-code 选择朴素 token overlap + 类型权重 + recency 平滑：\n朴素，所以确定。同一个 prompt + 同一个 memory pool，永远给同一个排序。 类型权重显式，所以可解释。你能在代码里看到 user=1.4, feedback=1.2, project=1.0, reference=0.7。 recency 用半衰期函数而不是硬阈值，所以排序是平滑变化的，不会因为某个 entry 越过 7 天的边界突然跳进跳出。 更进一步：memory 的写入也只在 turn 边界发生，绝不在 turn 中途。原因相同——一个 turn 内部如果 memory 被改写，下一次工具调用拼装 prompt 时整段 suffix 改变，cache 又一次崩塌。所有 memory 写操作（包括用户喊出的 /remember、agent 自己 propose 的修订）都被攒到 turn 收尾时统一落盘。\n这一节是 Memory 三部曲里最反直觉的一节：不是因为简单所以选了文件式，而是因为 cache 经济学逼着你必须简单。任何\u0026quot;看起来更聪明\u0026quot;的检索方案，都要先回答它怎么不破坏 prefix cache。\n6. 一段最小可跑的 typed memory 为了把上面四步具体化，本文配了一个 typed_memory.py（见 code/04-why-memory-system/，145 行，stdlib only）。它做了五件事：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 @dataclass class MemoryEntry: path: Path type: MemType # \u0026#39;user\u0026#39; | \u0026#39;project\u0026#39; | \u0026#39;reference\u0026#39; | \u0026#39;feedback\u0026#39; updated_at: float summary: str TYPE_WEIGHT = {\u0026#34;user\u0026#34;: 1.4, \u0026#34;feedback\u0026#34;: 1.2, \u0026#34;project\u0026#34;: 1.0, \u0026#34;reference\u0026#34;: 0.7} def score(entry, prompt, now): overlap = len(set(tokens(prompt)) \u0026amp; entry.tokens()) age_days = (now - entry.updated_at) / 86_400 recency = 1.0 / (1.0 + age_days / 7.0) # 半衰期 1 周 return (overlap + 0.5 * recency) * TYPE_WEIGHT[entry.type] discover() 走三层根目录（cwd / parent / ~/.agent），rank() 取 top-K，write_index() 渲染成 - [summary](file.md) — type=... 的 MEMORY.md。最后 assemble_prompt() 把它拼成 static_prefix + memory_index + dynamic_suffix。\n跑一次 demo，对同一个 memory pool 喂两个不同 prompt：\n输入 帮我写 cli-agent blog 第四篇 → 索引 top-2 是 project_blog + user_role。 输入 记得不要在文章里加 emoji → 索引 top-2 切换为 feedback_no_emoji + user_role。 user_role 总是在——因为 type weight 1.4 的 baseline 让用户身份永远占一个 slot；而第二位会跟着 prompt 内容切换。这就是类型化 + 启发式排序的可观测性，是 vector DB 永远给不出来的。\n7. 把这一切串起来：四个判断题 读到这里，你应该能用四个判断题验收一个 agent 的 memory 系统是否\u0026quot;工业级\u0026quot;：\n能不能 git diff？ 如果不能，这个 memory 系统天然没法 code review，team 协作必然出事。 同一 prompt 是否给同一索引？ 如果不能，cache 经济学一定崩。 每条 memory 有没有显式 type？ 如果没有，TTL、权重、扫描策略就只能一刀切。 MEMORY.md 是不是索引而不是 blob？ 如果是 blob，prefix cache 也保不住。 claw-code 的 memdir 在这四题上都得分。这不是巧合，是被 LLM 工程的硬性约束（KV cache、token 预算、可观测性、git workflow）逼出来的最优解。\n下一篇预告：Memory 存储、检索与失效 本文回答了 why 与 what：为什么不用 RAG、为什么类型化、为什么索引/blob 分离、为什么 cache 经济学决定一切。 下一篇（Article 5: Memory 存储、检索与失效） 会回答 how：\nentry 的 frontmatter 长什么样、memdir.ts 的 atomic write 怎么保证不损坏索引； findRelevantMemories.ts 的打分函数完整拆解，含 role match 与 negative filter； TTL / staleness / 矛盾检测的三种失效路径； 一个 ~200 行的 demo：memdir_lite.py，跑一遍 write → scan → age → rank 的完整链路。 第 6 篇则会进一步回答 agent 与 memory 的协同进化：feedback 如何反向修改 system prompt，memory 如何成为 agent 的\u0026quot;长期人格\u0026quot;。\n下篇见。\n引用 src/reference_data/subsystems/memdir.json — taxonomy 名单 memdir/{memoryTypes.ts, memoryAge.ts, memoryScan.ts, findRelevantMemories.ts, paths.ts, teamMemPaths.ts, teamMemPrompts.ts, memdir.ts} — 实现 src/transcript.py — compaction，与 memory 的对照 PDF §3, §3.3 — SYSTEM_PROMPT_DYNAMIC_BOUNDARY 与 cache 边界 你眼前这个 Claude Code session 的 MEMORY.md — 最佳教材 ","permalink":"https://mzf666.github.io/cli-agent/zh/posts/04-why-memory-system/","summary":"为什么工业级 Agent 必须有 Memory？user/feedback/project/reference 四类记忆模型、MEMORY.md 索引契约。","title":"Why Memory System：让 Agent 记住该记住的"},{"content":" 系列第 5 篇 / 共 6 篇。上一篇我们论证了「为什么工业级 Agent 必须有 Memory」， 把 user / feedback / project / reference 四类记忆模型摆上了桌。 本篇是 Memory 三部曲的核心实现篇——把「写一条 memory 之后，下次怎么取、怎么知道它过时了」这条全链路彻底拆开。\n0. 三个把 memory 玩坏的常见姿势 文件式 memory 在 demo 里看起来再朴素不过：往 .md 里 append 几行，下次启动时再 read_to_string 读回来。但工业级落地里，绝大多数线上事故都来自三个看似无关、其实根因相同的坑：\n写入时机不对，prompt cache 雪崩——你以为只是新增了一条 fact，结果 prefix 字节流偏移，整段 system + memory 段命中率归零，下一轮请求变得又贵又慢。 读取没做 verify，agent 用了过期 fact——memory 里写着「entry file 是 src/main.py」，三天前重构改成了 src/app/main.py，agent 却拿旧坐标去 patch 文件，silent 地引入 bug。 没监控 cache miss，错误悄悄发生——前两个坑如果可观测，至少能事后追溯；如果没人盯 cache_read_input_tokens 的曲线，就只能靠用户骂街才知道出事了。 claw-code 给这三个坑各自配了一套解法，分别落在 src/session_store.py、src/transcript.py 和 rust/crates/runtime/src/conversation.rs。本文按这三坑的因果顺序走一遍，最后用一个 \u0026lt; 150 LOC 的 Python demo 把核心不变量复现出来。\n1. 文件 vs DB：为什么 Memory 不上数据库 第一个让人犹豫的设计选择：memory 该放数据库还是放文件？claw-code 给的答案非常旗帜鲜明——Session 走结构化 JSON、Memory 走 plain markdown，两者分流。\nSession 那一侧，src/session_store.py 里定义了 StoredSession(session_id, messages, ...) 这种 dataclass，序列化后落到 .port_sessions/{id}.json。它需要严格的 schema、原子写、按 ID 索引——本质上是 transcript 的物化，给 resume / replay / debug 服务，DB-like 的诉求很自然。\nMemory 那一侧则完全相反。它是 typed paths 下的纯 markdown 文件——memdir/user.md、memdir/project.md、memdir/feedback/*.md。选择文件而非 DB 的四条理由都很务实：\nhuman-readable：用户随时可以 cat、可以编辑、可以拒绝某条 fact 被记下来。memory 是给人和 agent 共同所有的资产，不是黑箱。 git-versionable：team memory 直接进 repo，code review 流程天然就 review 了 agent 的\u0026quot;知识更新\u0026quot;。DB 行做不到这点。 零 schema migration：markdown 没有列、没有索引、没有 ALTER TABLE。新加一类 memory 就是新建一个目录。 grep 可调试：production 出事的时候，工程师不需要 spin up 一个 sqlite client，一行 rg \u0026quot;src/main.py\u0026quot; memdir/ 就够了。 这套选择背后还有一个隐含前提：memory 的 cardinality 不会无限膨胀。session 可能成千上万，memory 在合理使用下永远是「O(用户/项目数 × kind)」量级。文件系统对这个量级毫无压力。\n2. 检索：static path 在前、dynamic relevance 在后 把 memory 写下去之后，下一轮 turn 启动时怎么把对的几条捞出来？claw-code 用了一个非常工业的两阶段流水线：\nStatic path 阶段：paths registry（在 TS 子树里对应 memdir/paths.ts 与 teamMemPaths.ts）维护一份「已知存在、必扫」的固定路径清单。每次 turn 启动先按这份清单读盘，得到一个 bound 的 working set。 Dynamic relevance 阶段：在 working set 里，再用 findRelevantMemories.ts 做相关性打分，按 query / 当前文件上下文 / kind 过滤出 top-K。 顺序非常关键——必须 static 在前、dynamic 在后。把这两步反过来——先做相关性打分、再用 static path 兜底——表面上似乎更\u0026quot;智能\u0026quot;，实际工程后果是灾难性的：相关性打分要扫的候选集会从「几十个 known path」膨胀到「整个 workspace 下所有 markdown」，O(N) 的代价里 N 直接变成了项目所有文件数。在 monorepo 里，这等于把 turn 启动延迟拖到秒级。\n正确的心智模型是：先 bound working set，再 narrow within it。Static path 是粗筛，dynamic relevance 是精排，两个阶段串行执行，复杂度才能稳定在 O(K_paths) + O(|working_set|)。\n3. Verify-before-use：从 Verification Agent 借来的硬纪律 到这里 memory 已经被写下、被读出、被排好序——但它还不能直接用。这是本文第一个想立起来的核心观点。\nMemory 注入到 prompt 里的，从 LLM 的视角看是一段 context。但从工程语义看，它是 claim——「我宣称 entry file 在 src/main.py」「我宣称用户偏好 4-space 缩进」。Claim 的本质是过往观察的快照，它没有任何机制保证「现在仍然为真」。\nclaw-code 的硬纪律是：任何要被 agent 用来推荐改动 / 修改文件 / 给出回答的 memory claim，必须先经过工具 verify 取到 evidence。具体落地是：\nRead(src/main.py) 确认文件存在、看一眼前几行； Grep(\u0026quot;def main\u0026quot;, src/) 确认入口函数还在； Glob(\u0026quot;src/**/main.py\u0026quot;) 确认坐标没漂。 这条纪律的来源是 PDF 第 6.7 节的 Verification Agent，原文措辞掷地有声：\n\u0026ldquo;You must run the command, never just read code.\u0026rdquo;\n这不是吹毛求疵——它直击 agent 失败模式的核心。LLM 天然倾向于\u0026quot;看着像就当它是\u0026quot;，而 memory 的存在恰恰会强化这种倾向：因为 memory 是被反复写下来的\u0026quot;重点信息\u0026quot;，agent 对它的置信度会比对随机 context 更高，结果一旦它过时，agent 还会理直气壮地基于错误前提行动。\n反模式 ↔ 正模式对照非常清晰：\n反模式 正模式 memory 说 X 在 path P，直接 patch P memory → Read(P) verify → 若失败先 Grep 重新定位 → 再 patch memory 说用户偏好 X，直接套用 memory → 在当前 context 里二次确认（如 lint 配置仍存在）→ 再套用 memory 说库 L 在用 v1，直接生成 v1 API memory → 读 lockfile / pyproject.toml 取实际版本 → 再生成 把 verify 当成纪律意味着：memory 的价值不是\u0026quot;省去查证\u0026quot;，而是\u0026quot;把查证的搜索空间收敛成 O(1)\u0026quot;。Memory 告诉你去哪儿验证，工具告诉你那儿是不是真的。\n4. Compaction 的两层影响：触发 + health probe 写、读、verify 都聊完了，接下来是「memory 长大了怎么办」。session 上下文不可能无限增长——既有模型的 context window 上限，也有 prompt cache 的代价上限。rust/crates/runtime/src/conversation.rs 里给的解法叫 auto compaction：\n1 fn maybe_auto_compact(\u0026amp;mut self) -\u0026gt; Option\u0026lt;AutoCompactionEvent\u0026gt; 触发阈值默认是 100K input tokens，可以由环境变量 CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS 覆盖。命中阈值后，runtime 会做两件事：\n调用 rust/crates/runtime/src/summary_compression.rs 把历史消息压成一份 summary，配合 record_compaction(summary, n_kept) 落账，并发出一个 AutoCompactionEvent，让上层订阅者（UI、metrics）知道刚刚发生了一次压缩。 紧接着调用 run_session_health_probe()——这一步是 OSS 项目里少见的 production-grade 操作。 1 fn run_session_health_probe(\u0026amp;mut self) -\u0026gt; Result\u0026lt;(), String\u0026gt; 这个 probe 干一件事：验证 compaction 之后 agent 还能不能正常 invoke 工具。具体做法是跑一次非破坏性的 glob_search 之类的轻量调用，确认工具 router、permission、session id binding 都还在。\n为什么需要这一步？因为 summary_compression 是基于 LLM 的，它有非零概率把\u0026quot;agent 自己是谁、当前在哪个 worktree、有哪些 MCP 工具可用\u0026quot;这类身份性 memory 一并压成了散文。压完看起来文采飞扬，下一轮 agent 一调用工具就拿不到 binding，session 直接死掉。\nHealth probe 的存在，把这种 silent 损坏从「下一次用户触发功能时才暴露」提前到了「compaction 完成的瞬间」。这是对 transcript 层做了一次\u0026quot;sanity check\u0026quot;——src/transcript.py 里 TranscriptStore.compact / replay / flush 三个方法的设计也是同一个哲学：每次结构性变更都要能复现回放，才允许提交。\n5. PromptCache 失效作为一等信号 第三个坑——「错误悄悄发生」——是这一节的主题，也是本文第二个要立起来的核心观点。\nclaw-code 在 conversation.rs 里专门定义了一个 event 类型：\n1 2 3 4 5 6 7 8 #[derive(Debug, Clone, PartialEq, Eq)] pub struct PromptCacheEvent { pub unexpected: bool, pub reason: String, pub previous_cache_read_input_tokens: u32, pub current_cache_read_input_tokens: u32, pub token_drop: u32, } 这个 struct 的核心是 unexpected 这个布尔字段。Runtime 在每个 turn 后比较 current_cache_read_input_tokens 与 previous_cache_read_input_tokens：如果用户没有显式触发任何应当导致 prefix 变化的操作（没新增 memory、没改 system prompt、没切 worktree），但 token_drop 仍然 \u0026gt; 0，就把 unexpected 标成 true，并在 reason 里写明诊断结论。\n为什么要把它做成 first-class signal？因为 silent cache invalidation 是工业 agent 系统里最容易亏钱、最难定位的一类故障：\n钱包黑洞：cache_read 走的是优惠定价（通常是常规 input 价格的 1/10），cache miss 直接退化成全价 input。一个高频用户 cache 命中率从 90% 跌到 30%，月度账单可以涨 3 倍，但功能\u0026quot;看起来\u0026quot;没坏。 延迟黑洞：cache 命中时 prefix 不需要重新跑 attention，TTFT 显著更短。Cache miss 后 p50 延迟翻倍，用户体感变卡，但日志里没有 error。 诊断地狱：如果不监控，工程师只能在用户投诉之后逐 turn 比对 prefix bytes，找出是哪一行 memory / 哪一段 index 顺序变了——这是噩梦。 PromptCacheEvent.unexpected = true 的常见触发原因有：某次 memory 写入位置在 prefix 中段而非末尾、relevance ranker 输出顺序非确定、index 文件被外部进程重写、CLAUDE.md 被编辑器重新格式化导致字节差。把这些都收敛成一个可订阅的 event，metrics dashboard 才能画出曲线、alert 才能在凌晨叫醒 oncall。\n工程价值就一句话：可观测就能修，不可观测就死。Memory 子系统的稳定性，最终是被 PromptCache 这条 metrics 兜底的。\n6. 全链路时序图 把上面五节串起来，单个 turn 内 Memory 的完整生命周期是这样：\nsequenceDiagram participant Turn participant Mem as MemoryStore (.md files) participant Cache as Prompt Cache participant Tool Turn-\u0026gt;\u0026gt;Mem: discover+scan+rank Mem--\u0026gt;\u0026gt;Turn: top-K entries Turn-\u0026gt;\u0026gt;Cache: assemble prefix (stable bytes!) Turn-\u0026gt;\u0026gt;Tool: verify-before-use (read_file/grep) Tool--\u0026gt;\u0026gt;Turn: evidence Turn--\u0026gt;\u0026gt;Mem: append/update entry Note over Cache: PromptCacheEvent emitted if token_drop\u0026gt;0 注意时序里两个细节：\nassemble prefix 的字节稳定性是个契约。如果 ranker 输出顺序非确定，或者 markdown 渲染夹了 timestamp，就会破坏这个契约，触发 unexpected cache event。 append/update entry 发生在 verify 与工具调用之后，而不是之前。这保证了写进 memory 的都是被 evidence 验证过的事实，而不是 claim。 7. 一个 \u0026lt; 150 LOC 的 Python demo 下面这个 demo 把上面的不变量浓缩成了 ~140 行 stdlib Python，可以直接 python3 memory_store.py 跑。完整代码在 cli-agent/code/05-memory-storage-retrieval/memory_store.py，这里只摘核心两段。\n第一段：cache fingerprint = sha256(static prefix)——把 system prompt 与 memory 快照拼起来取 hash，作为本地版的 previous_cache_read_input_tokens 比对锚点。\n1 2 3 4 5 6 def assemble_prefix(static_system: bytes, store: MemoryStore) -\u0026gt; bytes: body = \u0026#34;\\n\u0026#34;.join(e.text for e in store.load_all()).encode(\u0026#34;utf-8\u0026#34;) return static_system + b\u0026#34;\\n--- MEMORY ---\\n\u0026#34; + body def cache_fingerprint(prefix_bytes: bytes) -\u0026gt; str: return hashlib.sha256(prefix_bytes).hexdigest()[:16] 第二段：verify-before-use——把 memory claim 强制 round-trip 一次工具，不通过就 raise，永远不允许 agent 直接用 claim。\n1 2 3 4 5 def verify_before_use(claim: str, tool: Callable[[str], str | None]) -\u0026gt; str: evidence = tool(claim) if evidence is None: raise RuntimeError(f\u0026#34;verify failed for claim: {claim!r}\u0026#34;) return evidence Demo 跑三个 turn：\nTurn 1：写一条 fact memory，调 verify_before_use 拿到 evidence，记一次 fingerprint。 Turn 2：往 store 灌 40 条 trivial pref 把 token 总量推过 budget，触发 auto_compact_if_over(token_budget=200, keep_last=2)。compaction 完成后再记一次 fingerprint。 Turn 3：什么都不做，再记一次 fingerprint。 Demo 末尾断言两个不变量：\n三次 fingerprint 之间，变化次数恰好等于 1（只有 turn 2 的 compaction 应该改 prefix，turn 1 的写入是 prefix 末尾追加、在 demo 抽象里也算变化但与 turn 2 合并计数；turn 3 的 idle 必须 0 变化）。 fps[1] == fps[2]——idle turn 不许扰动 prompt cache，否则就是个 bug。 实际跑出来：\n[turn 1] verified -\u0026gt; src/main.py exists, 412 bytes [turn 2] auto_compact triggered=True, compactions=1 [turn 3] fingerprints=[\u0026#39;18e95...\u0026#39;, \u0026#39;8884d...\u0026#39;, \u0026#39;8884d...\u0026#39;] [ok] fingerprint changes=1 (compaction-only, as designed) 把这个本地 invariant 翻译回生产侧，就是：任何让 fingerprint 在 idle turn 变化的 PR，都应该在 CI 阶段被卡住。这正是 PromptCacheEvent.unexpected 在生产环境扮演的角色。\n8. 收束：写、读、防过时——三件事都靠纪律 这一篇我们把 Memory 子系统的实现内核拆成了五个互锁的部分：\n存储选 file 不选 DB——是为了 human-readable + git-versionable + 零 schema migration。 检索先 static path 再 dynamic relevance——是为了把 O(N) 收敛成 O(K)。 使用必须 verify-before-use——是为了把 memory 当 claim 不当 fact，借自 Verification Agent 的硬纪律。 压缩触发后必跑 health probe——是为了把 compaction 引入的 silent breakage 提前到瞬间暴露。 失效通过 PromptCacheEvent.unexpected 一等信号化——是为了不让钱包黑洞和延迟黑洞悄悄吃人。 五件事没有一件依赖\u0026quot;模型够强\u0026quot;，也没有一件依赖\u0026quot;prompt 写得巧\u0026quot;。它们全都是工程纪律：在哪个时机写、按什么顺序读、用之前必须 verify、改完必须 probe、变化必须可观测。这正是工业级 Agent 框架与玩具 demo 的真正分水岭。\n下一篇预告 第 6 篇也是本系列的收官：Memory 与 Agent 的协同演化。我们会把镜头从 memory 内部拉远，去看它怎么和 Hooks / MCP / CLAUDE.md 协作，把 agent 的\u0026quot;学习\u0026quot;变成跨会话、跨项目、跨团队的进化——以及这种演化模式对未来 agent 框架设计的隐含约束。\n引用 src/session_store.py src/transcript.py（TranscriptStore.compact / replay / flush） rust/crates/runtime/src/conversation.rs（maybe_auto_compact、PromptCacheEvent、run_session_health_probe） rust/crates/runtime/src/summary_compression.rs PDF §6.7（Verification Agent 纪律的来源） 本文 demo：cli-agent/code/05-memory-storage-retrieval/memory_store.py ","permalink":"https://mzf666.github.io/cli-agent/zh/posts/05-memory-storage-retrieval/","summary":"文件式 memory 的写入/读取/相关性匹配、index 加载策略、读时 verify 流程、与 prompt cache 的交互。","title":"Memory 的存储、检索与失效"},{"content":"0. 从「记忆回放」到「记忆进化」 前两篇我们把 memory system 拆成了两半：第 4 篇讲「为什么要有」（context window 不够、跨 session 知识断片、verification 没有 anchor），第 5 篇讲「怎么存怎么取」（文件式 storage、index 注入、读时 verify、与 prompt cache 的合作）。\n但如果故事到这里就结束，memory 只是一个录音机——你昨天说过什么、做过什么，今天能播放出来。这远远不够。一个真正可用于生产的 agent 必须做到一件事：犯过的错不再犯第二次。这才叫「记忆进化」（memory evolution）。\n录音机和进化的区别在于一个闭环：\ntool 失败 → hook 捕获 → 写入 memory → 下次 session system prompt assembly 时读到 → agent 行为改变 → 下次成功 任何一个齿轮缺位，闭环就断。本文沿着这条闭环，依次拆四个齿轮：\nHooks——闭环的 capture 端，把 runtime 信号引到 memory； MCP——独立于本地 memory 的第二条记忆通道，让团队级最佳实践跨 agent 注入； CLAUDE.md——项目契约，把人类约定固化为 system prompt 的一部分； Skills + Plugins——把好行为制度化，从「每次重新发现」升级为「永久封装」。 本文是系列第 6 篇，也是终篇。文末会回顾整条主线，并埋一个彩蛋：当你把这套纪律推到极致，下一步就是领域特化 agent（domain-specialized agents）。\n1. Hooks：Memory 反馈环的注入点 整个闭环里，Hook 是离 runtime 最近的一环。在 claw-code 的 Rust 工作区里，它的定义集中在两个文件：\nrust/crates/runtime/src/hooks.rs：定义 HookEvent 枚举和分发器。 rust/crates/plugins/src/hooks.rs：plugin 侧的 hook 协议适配。 我们关心三类事件：\n1 2 3 4 5 pub enum HookEvent { PreToolUse { tool: String, input: Value }, PostToolUse { tool: String, input: Value, output: Value }, PostToolUseFailure { tool: String, input: Value, error: String }, } Hook 的返回值结构（PDF §8.5）允许它做四件事：\n追加 message——把一段文字加到对话里； 注入 additionalContext——在下一轮 system prompt assembly 时被读取； 阻断 continuation——直接终止 agent 的 next step； 更新 MCP tool output——在 output 走回 model 之前重写它。 请注意第 2 条。additionalContext 不只能影响下一个 turn，它可以被持久化。当 hook 同时把这条 context 追加到 MEMORY.md，下次 session 启动时 system prompt assembly 会再次读到它——这就是闭环的 capture 端。\n最自然的应用是 PostToolUseFailure：\n1 2 3 4 def on_post_tool_use_failure(tool, input, error): line = f\u0026#34;- [debug] tool {tool} failed: {error[:80]} on {today()}\u0026#34; append_to_memory(line) return {\u0026#34;additionalContext\u0026#34;: f\u0026#34;Note: {tool} just failed because {error}. Avoid repeating.\u0026#34;} 这一段不到 5 行代码，但它跨越了两个时间尺度：additionalContext 解决本 session 的连续犯错（同一个 turn 里别再调一次失败的 tool），append_to_memory 解决跨 session 的重复犯错（明天另一个 worktree 启动时就知道）。\n工程上要记住一句话：Hook 是 memory 唯一可信的 writer。直接让 LLM 自己写 memory 容易引入幻觉（claim without evidence），但 hook 拿到的是真实的 tool 调用结果——它写下去的失败原因是事实，不是叙述。\n2. MCP：第二条记忆通道 MCP（Model Context Protocol）通常被介绍为「让外部服务暴露 tool」。但从 memory 的视角看，它是第二条独立的记忆通道。\nPDF §7.5 描述 claw-code 的 system prompt assembly 时，会调用 getMcpInstructionsSection(mcpClients)：把每个连接的 MCP server 在握手阶段给出的 instructions 字段，拼成 prompt 的一段。\n这意味着一台 MCP server 可以同时 ship 两样东西：\ntool schema：它能干什么； operator-style instructions：它应该怎么被使用——一段自然语言指南、约定、避坑提示。 为什么这是「第二条记忆通道」？因为本地 MEMORY.md 是用户/项目维度的，每个仓库、每个开发者都可能不一样；而 MCP instructions 是服务维度的，由提供方维护，跨所有连接它的 agent 共享。\n工程价值非常大。设想一家公司有内部 DevOps MCP server：\n1 2 3 4 // MCP server 握手返回 { \u0026#34;instructions\u0026#34;: \u0026#34;Always run `internal-deploy --dry-run` before any prod deploy.\\nNever skip the staging gate.\\nIf `kube-ctx` shows prod, require human confirmation.\u0026#34; } 这段话不需要每个团队成员手动加进自己的 CLAUDE.md——只要他们的 agent 连了这台 MCP server，下一轮 system prompt assembly 时这段约定就自动注入。MCP 让「公司级最佳实践」获得了一个集中维护、跨 agent 自动分发的载体。\n放回闭环里看，MEMORY.md 是 bottom-up 的（hook 从 runtime 失败里学到），MCP instructions 是 top-down 的（团队从经验里写下）。两条通道在 system prompt assembly 时合流。\n3. CLAUDE.md：项目契约 如果说 MEMORY.md 是 agent 的工作笔记、MCP instructions 是服务说明书，那么 CLAUDE.md 是项目契约——人类对 agent 在「这个仓库里应该怎么干」的明确约定。\n我们直接看 claw-code 自己的 CLAUDE.md（仓库根目录）：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Detected stack - Languages: Rust. - Frameworks: none detected from the supported starter markers. ## Verification - Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace` - `src/` and `tests/` are both present; update both surfaces together when behavior changes. ## Repository shape - `rust/` contains the Rust workspace and active CLI/runtime implementation. - `src/` contains source files that should stay consistent with generated guidance and tests. - `tests/` contains validation surfaces that should be reviewed alongside code changes. ## Working agreement - Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows. - Keep shared defaults in `.claude.json`; reserve `.claude/settings.local.json` for machine-local overrides. - Do not overwrite existing `CLAUDE.md` content automatically; update it intentionally when repo workflows change. 短、操作化、可执行。值得逐段品：\nDetected stack：把「这是个 Rust 项目」写成事实，省掉 agent 每次启动时去 sniff Cargo.toml。 Verification：三条命令——cargo fmt、cargo clippy --workspace --all-targets -- -D warnings、cargo test --workspace——这是「写完代码以后必须跑什么」的契约。第 5 篇讲过，这种可执行 anchor 让 verification 可以自我验证。 Repository shape：明确 rust/、src/、tests/ 三处的关系。 Working agreement：四条工作约定，最后一条是真正的元规则—— Do not overwrite existing CLAUDE.md content automatically; update it intentionally when repo workflows change.\n这条是整篇文档的灵魂。它在说：memory 是 workflow 的一部分，不只是 workflow 的描述。当工作流变了，CLAUDE.md 必须被有意识地更新；反过来，CLAUDE.md 的更新也是工作流变化的官方公告。\n把三条通道并排看：\n通道 维护者 作用域 更新频率 MEMORY.md hook 自动 + 人工 项目/用户 每次 session MCP instructions 服务提供方 跨 agent 服务版本 CLAUDE.md 人类有意识 项目 workflow 变更时 三者在 system prompt assembly 阶段合流，给 agent 一个多源、有层次的上下文。\n4. 协同图景 把上面四节合起来，每一个 turn 实际上长这样：\nflowchart LR subgraph \u0026#34;Each Turn\u0026#34; SP[System prompt assembly] --\u0026gt; CMD[CLAUDE.md + MEMORY.md + MCP instructions] CMD --\u0026gt; AG[Agent decides] AG --\u0026gt; T[Tool exec] T --\u0026gt; H[PostToolUse hook] H --\u0026gt;|additionalContext / updatedInput| AG H --\u0026gt;|append finding| MEM[(Memory files)] end MEM -. next session .-\u0026gt; CMD Plugin[(Plugin/Skill state dirs)] -. next session .-\u0026gt; CMD 注意两条虚线——它们才是「进化」。实线是单 session 内部的回放，虚线是跨 session 的进化。一个 agent 是「录音机」还是「学习者」，区别就在那两条虚线是否真正连通。\n5. 长周期失败模式（必须警惕的 4 种） 把闭环画出来容易，运行一年不出问题难。下面四种是工程上反复踩到的坑：\n5.1 Silent drift：PromptCacheEvent.unexpected=true 但 fingerprint 稳定 第 5 篇讲过 prompt cache fingerprint 的作用。问题是：fingerprint 稳定不等于语义稳定。当上游 model 静默升级、tokenizer 微调、模板渲染顺序换了，可能 fingerprint 一致但 cache 命中率突然崩盘——unexpected=true 比例飙升。\n现象：钱包黑洞。每个 turn 的 prefill cost 翻倍，但 agent 行为看似没变。\n防线：把 cache_hit_rate 作为 SLI 持久化到 metrics，跨周做趋势告警，而不是只盯单 session。\n5.2 Identity loss：post-compaction 的健康探针失败 run_session_health_probe() 是 compaction 之后的强制 sanity check（PDF 第 6 章）。当它失败，意味着 agent 在压缩之后不知道自己是谁了——project root 丢了、当前任务 id 丢了、CLAUDE.md 摘要被吞掉。\n现象：agent 突然开始问「我们刚才在做什么？」或者用通用模板回答原本有项目特化答案的问题。\n防线：把健康探针失败当作强制 hard-stop，要求人工介入或回滚到上一个 checkpoint，绝不能让它继续 silently 跑。\n5.3 Repeated mistake loop：PostToolUseFailure 没写 memory 这是闭环最常见的断点。Hook 触发了、错误信息也拿到了，但没有 append 到 MEMORY.md——可能是 hook 实现忘了，可能是写入路径权限问题，可能是 atomic write 失败被吞了 exception。\n现象：同样的 tool 错误一周内被 agent 触发 50 次，每次都是「初次见面」。\n防线：把「failure → memory write」做成 contract test，pre-merge 强制跑一次注入失败的回归 case。\n5.4 Verification avoidance + 80% illusion（PDF §6.7） 最隐蔽。Memory 里只存 claim 不存 evidence——「我已经修复了这个 bug」「测试通过了」「部署成功了」。下一次 session 读到这些 claim 当事实信，连锁失败。\nPDF §6.7 把它叫做「Verification Avoidance」：agent 倾向于声称完成而不是证明完成。配合 80%-of-the-way illusion——做完 80% 就感觉做完了——memory 就会沉淀一堆假阳性。\n防线：memory 写入必须配 evidence anchor。例如「✓ test suite passed」必须带上 cargo test --workspace 的实际 stdout 摘要或 exit code，不能只写一句「passed」。这就是为什么 claw-code 的 CLAUDE.md 里要把 cargo fmt / cargo clippy / cargo test 写明——这些命令本身就是 evidence 生成器。\n6. Skills + Plugins：把好行为制度化 闭环跑顺了之后，下一个问题是：怎么让好行为不依赖个体灵感？\nPDF §7 给出的答案是 Skills + Plugins。\nSkills 是 prompt-native 的 workflow 包，每个 skill 是一个 markdown 文件，frontmatter 声明它需要哪些 tool、什么条件下激活。例如：\n1 2 3 4 5 6 7 8 9 10 --- name: rust-pr-review allowed-tools: [\u0026#34;bash\u0026#34;, \u0026#34;read_file\u0026#34;] trigger: \u0026#34;when reviewing a Rust PR\u0026#34; --- 1. Run `cargo fmt --check` and report diffs. 2. Run `cargo clippy --workspace --all-targets -- -D warnings`. 3. Run `cargo test --workspace`. 4. Summarize failures with file:line anchors. 这不是 memory，但它复用了 memory 的纪律：把「应该怎么做」固化成可执行 anchor。Skill 的好处是条件性 context 注入——只有匹配 trigger 时才占 prompt 预算，不像 CLAUDE.md 永远在场。\nPlugins 是 Skill 的分发载体（PDF §7.2，相关代码在 claw-code 的 src/utils/plugins/loadPluginCommands.ts）。Plugin 在加载时会暴露一组环境变量给 skill 使用：\n${CLAUDE_PLUGIN_ROOT}：plugin 安装目录； ${CLAUDE_PLUGIN_DATA}：plugin 的可写数据目录（可以放跨 session 的状态）； ${CLAUDE_SKILL_DIR}：当前 skill 的资源目录； ${CLAUDE_SESSION_ID}：当前 session 的唯一 id； ${user_config.X}：用户在 plugin manifest 里声明的可配置项。 注意 ${CLAUDE_PLUGIN_DATA}——它就是 plugin 自己的「memory 抽屉」。Memory / Skill / Plugin 三者的关系可以这样理解：\nMemory 沉淀经验，Skill 把经验封装成可调用的动作，Plugin 把动作做成可分发的包。\n闭环到这里就 self-reinforcing 了：runtime 失败 → hook 写 memory → 经验稳定后被人手 promote 成 skill → skill 被打包成 plugin → plugin 在团队里分发 → 整个团队的 agent 直接拿到「现成的好行为」，不必每个人重新踩一遍坑。\n7. 代码 demo：三个 turn 看 memory 进化 完整代码在 cli-agent/code/06-memory-evolution-and-agent/memory_evolution.py。它 stdlib only、\u0026lt;150 LOC，用最小模型还原前文的闭环。\n核心结构：\n1 2 3 4 5 6 7 8 9 10 11 12 13 class Agent: system_prompt_sections = [ BASE_INSTRUCTIONS, load(\u0026#34;CLAUDE.md\u0026#34;), load(\u0026#34;MEMORY.md\u0026#34;), # ← 闭环的 read 端 MCP_INSTRUCTIONS, ] def on_post_tool_use(self, tool, args, result): if not result.ok: append_memory(f\u0026#34;- [debug] tool {tool} failed for {result.reason} on {today}\u0026#34;) elif tool.startswith(\u0026#34;verify_\u0026#34;) and result.ok: append_memory(f\u0026#34;- [verified] {tool}({args}) -\u0026gt; {result.value} on {today}\u0026#34;) 跑 3 个 turn：\nTurn 1：agent 调用 deploy(env=\u0026quot;prod\u0026quot;)，hook 截获错误「missing dry-run gate」，append 到 MEMORY.md。 Turn 2：新 session 启动，system prompt assembly 把上一条 lesson 注入。Agent 看到后改先跑 verify_dry_run。 Turn 3：dry-run 通过，verified 事实也被记下。deploy 这次成功。 每个 turn 之间打印 MEMORY.md 的 diff，进化轨迹一目了然。读者可以直接 python memory_evolution.py 跑起来看。\n8. 系列回顾 + 下一步可以做什么 到这里，整个《CLI Agent 深入浅出》系列就走完了。回看 6 篇主线：\n第 1 篇 · 工业级 Agentic Workflow 总览——主循环、turn 边界、显式控制流为什么重要。 第 2 篇 · Tool 与 Permission——tool surface 的设计，permission boundary 是 trust 的边界。 第 3 篇 · Subagent / Plan / Worktree——并行隔离的三种姿势，沙箱即纪律。 第 4 篇 · Memory System 之 Why——为什么单靠 context window 撑不住，memory 是 verification 的 anchor。 第 5 篇 · Memory 的存储、检索与失效——文件式 storage、index 注入、读时 verify、与 prompt cache 的合作。 第 6 篇 · Memory 与 Agent 的协同演化（本篇）——Hooks/MCP/CLAUDE.md/Skills+Plugins 四个齿轮把闭环 self-reinforcing。 如果只能记一句话，那就是：\n工业级 agent = 显式控制流 + 类型化记忆 + 可观测闭环。\n显式控制流让你在每一个 turn 都知道控制权在谁手里；类型化记忆让你的 context 不再是字符串拼贴而是有 schema 的事实库；可观测闭环让你的 agent 跑得越久越聪明而不是越漂移。\n下一步可以做什么？把这套纪律推到极致，最自然的方向是领域特化 agent（domain-specialized agents）：\nDB ops agent：CLAUDE.md 写明 schema 约束、verification 是 explain plan、memory 沉淀慢查询模式、MCP 通道暴露内部 query gateway。 SRE agent：hooks 直接挂在 incident channel，memory 沉淀 postmortem 关键字，skill 封装 runbook，plugin 跨团队分发。 Scientific computing agent：CLAUDE.md 写实验规范，verification 是 reproducibility check，memory 存 hyperparameter trace + dataset fingerprint。 通用 agent 解决「能做」，领域特化 agent 解决「做对」。当一个领域里的失败模式被 hook 充分捕获、被 memory 充分沉淀、被 skill 充分封装、被 plugin 充分分发，这个领域的 agent 就真的能上生产了——这正是工业级 agent 这个词最终的意义。\n愿你写出的 agent，下次 session 比这次更聪明一点。\n引用 claw-code: rust/crates/runtime/src/hooks.rs、rust/crates/plugins/src/hooks.rs claw-code 仓库根 CLAUDE.md（本文 §3 完整引用） claw-code: src/utils/plugins/loadPluginCommands.ts（PDF §7.2） PDF §6.7（Verification 纪律）、§7（Skills/Plugins）、§7.5（MCP instructions）、§8.5（Hook 回馈） 本系列 1–5 篇 ","permalink":"https://mzf666.github.io/cli-agent/zh/posts/06-memory-evolution-and-agent/","summary":"Memory 与 Hooks/MCP/CLAUDE.md 的协同；feedback memory 的强化闭环；长周期演化中的失败模式。","title":"Memory 与 Agent 的协同演化"}]