Part 8 迷你 Claude Code 蓝图:自己造一个该怎么分层
墨叔:老哥,前面七章我们把齿轮一个个拆下来看过了——模型是那个蒙眼大厨,循环是递纸条的人,工具是手,记忆是工作台上的收纳术。今天这一章不拆了,咱们反过来:如果让你从零造一个迷你版 Claude Code,这些齿轮该按什么顺序、什么层次拼回去?
8.1 架构设计:我们要造什么
老哥,我问你一个最朴素的问题:如果今天老板拍板”咱也搞个内部用的代码 agent”,你打开编辑器,第一行代码会写在哪?
我猜你大概率会先写一个能跟模型对话的函数,然后顺手把”读文件""跑命令”塞进去,再外围套个 while 循环。这是最快跑起来的写法,但它有个致命问题:所有职责糊在一坨里。等你要加”流式输出""权限确认""上下文压缩”时,会发现每加一个功能都要改同一块代码,最后变成一团谁都不敢动的意大利面。
Claude Code 这类工具之所以丝滑、可维护,不是因为它用了什么黑科技,而是因为它从第一天起就把系统切成了职责清晰、依赖单向的层。你作为写了五年 Go 后端的人,对这套思维应该熟的——只不过这次”业务”换成了”让模型改代码”。
我们先把蓝图摆出来。一个迷你 Claude Code,从上到下大致五层:
[REPL / 前端层] 交互、渲染、权限确认、打断
|
[上下文工程层] 每轮决定"这张纸条该写什么"——拼系统提示、选历史、压缩、注入记忆
|
[Agent Loop 层] observe→think→act→observe 的主循环,终止条件与错误兜底
|
[工具执行层] 文件读写、shell 执行、搜索,带权限与沙箱
|
[模型适配层] 屏蔽不同厂商 API 的差异(OpenAI/Anthropic/本地),统一成 chat(messages, tools)
老哥你细看这个依赖箭头——永远是上层依赖下层,下层不认识上层。REPL 调上下文层,上下文层调 Loop,Loop 调工具层,工具层调模型适配层。反过来,模型适配层压根不知道上面有四层。这跟你在 Go 里写 handler → service → repo → db driver 是一条路数:handler 不关心 SQL 怎么拼,repo 不关心请求从哪个路由来。
我给你一个狠一点的类比。这五层就像一家装修公司的分工:REPL 层是前台接待(听业主需求、报价、签确认单);上下文工程层是项目经理(每去一趟工地前,把图纸、上次进度、注意事项塞进工长的公文包);Agent Loop 是那个转圈圈的调度中枢(“去现场→回来汇报→再派活”);工具执行层是真正动手的工人(砸墙、改电、刷漆);模型适配层是工人手里的那套工具——不管买的是博世还是牧田,干的活都一样。你不会让工人自己接客户电话,也不会让前台直接去刷漆。职责切错,公司就乱套。
为什么非得这么切?我从三个角度给你讲透。
第一,切层是为了”换零件不炸全局”。 模型适配层隔离了”用哪家模型”。今天用 Claude,明天换到本地开源模型,你只想改最底层一个文件,上面的 Loop、工具、前端一行都不动。这不就是你 Go 项目里”面向接口编程、依赖倒置”那套吗?ModelAdapter 是个接口,ClaudeAdapter 和 LocalAdapter 是两种实现,注入哪个用哪个。如果你把模型调用直接写进 Loop 里,换模型就是重写半套系统。
第二,上下文工程层单独拎出来,是因为它太容易变成性能瓶颈和失焦源头。 前面 Part 1 我们讲过,上下文窗口是张有限的桌子,往上面堆什么、丢什么,直接决定模型靠不靠谱。这个”堆什么”的决策,复杂到值得拥有自己的一层:它要读长期记忆、要选最近的 N 轮对话、要把超长的工具返回压缩成摘要、要在每轮开头贴上总目标。这些活儿如果塞进 Loop,Loop 就不纯了——Loop 该只关心”转圈”,不该关心”纸条怎么写”。
第三,REPL 层独立于一切逻辑,是因为”人和 agent 的接口”是独立演进的。 同一套 Loop + 工具,今天你用命令行交互,明天你想包个 IDE 插件、或者做成无头服务(像 Codex 那样云端跑、只回 diff),只要换 REPL 这层。Claude Code 的丝滑感,很大程度来自 REPL 层把”流式 token 渲染""可打断""权限弹窗”做透了,而这些跟”怎么改代码”毫无关系。
跟你的 Go 后端连一下:你肯定写过 middleware → handler → service → repo → driver 这种分层。区别只有一个——你那套的”决策”在各层里是写死的(路由表、if 分支),而 agent 系统的”决策”被集中到了 Agent Loop 那一层,由模型来出。其他的:依赖方向、单向调用、接口隔离、可替换底层,全是你驾轻就熟的东西。你不是在设计新范式,你是在给”决策源变成概率”的老分层换了个心脏。
我给你段真实经验层面的提醒:很多团队第一次造 agent,最爱犯的错就是把”权限确认”写进工具层——比如 run_shell 函数里弹个窗问用户。这就破坏了分层:工具层本该是个纯执行单元(甚至能在无头模式跑),它不该依赖”有个人在屏幕前”。正确做法是权限确认放在 REPL 层(或专门的权限网关),工具层只管执行,要不要先问人,是上层的事。这跟你后端”service 不弹窗、权限在 middleware 或 gateway 做”是一个道理。
老哥你可能会问:“层切错了,到底会痛到什么程度?值得我开工前花半天画这图吗?“我给你讲一个真实会发生的塌方链。假设有人图省事,把”上下文拼接”和”模型调用”都塞进 Agent Loop,又把”权限确认”塞进工具层。第一周 demo 跑得欢。第二周产品说”我们想接本地开源模型省成本”——你发现模型调用散在 Loop 三处,改一处漏两处,半天改完发现流式逻辑也绑死了某家 API 的格式。第三周要做无头批处理模式——你发现工具层弹窗直接卡死(没人在屏幕前),得把每个工具翻出来删弹窗。第四周要加”上下文压缩”——你发现拼接逻辑在 Loop 里和终止判断绞在一起,一动就出 bug。一个月后这套系统没人敢碰。这不是吓你,是分层债务和”面条代码债务”一模一样的复利增长:早期省的那半天画图,后面要还几周的重构。你写 Go 时信奉”过早抽象是万恶之源”,但 agent 这种”多职责、长链路、易变外部依赖”的系统,分层不是过早抽象,是防止概率系统把不确定性传染到每一层的基本隔离。模型是最大、最不可控的变数,把它沉到最底层、用适配层包住,上面所有层才能”假装模型是稳定的”去写逻辑——这正是依赖倒置在概率世界里的价值。
再补一句连接,收一下这一节:你后端做分层,动力常常来自”测试”和”替换实现”;agent 做分层,除了这两样,还多一条——隔离不确定性。哪层直接接触概率模型,哪层就背上了”可能抽风”的风险;把这个风险圈在最底层,上面的 Loop、上下文、前端都能用确定性的心态去写、去测、去信。你当年用接口隔离数据库,是为了”哪天换 DB 不痛”;今天用适配层隔离模型,是为了”哪天换模型(或模型抽风)时,上面纹丝不动”。动机同源,只是隔离的对象从”存储引擎”变成了”概率决策引擎”。
老哥,光说”分层”有点虚,我把每层对外暴露的”接口长什么样”给你钉一下,你就能照着画自己那套。注意看:每一层只暴露一个窄接口给上层,内部怎么折腾上层不管。REPL 层对上暴露 present(token) / confirm(action) -> bool / on_interrupt() -> signal;上下文工程层暴露 build(state) -> messages 和 compress(messages);Agent Loop 层暴露 run(goal) -> result,内部自己转圈;工具执行层暴露 execute(tool_call) -> ToolResult,内部管权限前置与沙箱;模型适配层暴露 chat(messages, tools) -> ModelResponse 和 chat_stream(...),内部管各家 API。你看,这五个接口就是五层之间的”契约”。你写 Go 时 type Repo interface { Query(...) },调用方只认接口不认实现——agent 这五层同理,每层都是”接口 + 实现”,上层拿接口编程。更有意思的是:这五层可以独立单测。给 build 喂一个假 state,断言它把目标钉在头部;给 execute 喂一个 rm -rf 的调用,断言它被拦;给 run 喂一个会连续调错工具的 mock 模型,断言它按 max_steps 兜底。你后端靠”接口可 mock”做单测,agent 靠”每层可 mock”做单测——分层的第一红利永远是”可测”,agent 不例外。
还有个分层相关的反模式我得点名:别让上层”跳过”下层直接调更下层。比如 REPL 层图省事,直接 import 工具执行层去跑命令,绕过了 Loop 和上下文层。这看着快,实则埋雷:绕过的调用不走上下文工程(结果不回流、不压缩),不走 Loop(没有错误兜底、没有步数限制),等于开了个”无纪律后门”。你后端也有同款禁忌——handler 直接 db.Query 绕过 service 层的事务与缓存,短期爽、长期数据不一致。agent 里这条禁忌更致命,因为绕过的调用连”模型决策”都跳过了,变成纯硬编码,那还不如别造 agent。所以分层约定里要写死一条:调用只能相邻层发生,禁止跨层穿透。这和你后端”分层调用规约(不允许 controller 直接调 mapper)“是同一句纪律,只是 agent 这边的”穿透”还会丢掉概率系统唯一的那点安全网。
【思考题】
- 蓝图里说”依赖永远上层指向下层,下层不认识上层”。如果模型适配层某天需要把”模型拒答了”这个信号传给 REPL 层弹个警告,你会怎么处理,才不破坏这个单向依赖?
- 上下文工程层如果和 Agent Loop 层合并,会失去什么能力?举一个你作为后端能立刻想到的”职责混在一起就难维护”的例子。
- 你现在的项目里,有没有哪一层其实也该像”模型适配层”这样被单独隔离出来、但你当时没做,导致换技术栈很痛?对照一下。
【参考答案】
- 不能让下层直接调上层(那会反向依赖)。正确做法是用返回值/异常往上冒泡:模型适配层返回一个统一的
ModelResponse,其中带一个状态字段(如status="refused"),Loop 层读到这个状态后,决定要不要把”模型拒答”作为一个普通事件写进上下文,再由 REPL 层在渲染时根据这个事件决定是否弹警告。换句话说,下层只产出”事实”,上层消费”事实”并决定”如何呈现”。这正是后端里”底层抛 error、上层决定怎么对用户展示”的模式——error 链往上走,但底层从不 import 上层的 UI。 - 失去的是”Loop 的可复用性与可测试性”。如果上下文拼接逻辑混在 Loop 里,那 Loop 就再也离不开”真实记忆/真实历史”跑单测了——你想测”模型连续调错工具时 Loop 怎么兜底”,还得先造一整套上下文。分层后,Loop 可以喂一个假的
ContextBuilder,纯测循环纪律。你后端肯定遇过类似的:把 SQL 拼在 handler 里,想单测 handler 逻辑就得先起数据库;抽成 repo 后,handler 用 mock repo 就能测。分层的第一收益往往是”可测试性”,agent 也一样。 - 太常见了。比如很多人把”发消息/通知”直接写进 service(调钉钉、发邮件散落在各业务函数里),等要换通知渠道或做无头测试时,得全项目改。这恰好对应模型适配层该隔离的本质:凡是”依赖一个外部、可能替换、且不应污染核心逻辑”的东西,都要沉到最底层做适配。你当年吃的亏,正是今天造 agent 时要把模型、要把权限网关、要把前端都隔离的同一份经验。
小结:迷你 Claude Code 分五层,依赖单向、上层指向下层,本质是 handler→service→repo 那套分层思维的平移,只是把”决策源”收进 Agent Loop 让模型出。分层的目的不是炫技,是换零件不炸全局、上下文工程独立演进、前端可替换。你早就是分层老手,这次只是换了心脏。
8.2 核心循环该怎么写
老哥,还记得 Part 1 的 1.3 吗?我们写过一个最简 Agent Loop:for 循环里,模型决策、代码执行工具、结果回流、直到 is_final_answer。那个骨架你当时说”跟事件循环没区别”,说得对。但墨叔要泼盆冷水:那个骨架能跑通 demo,可一旦要上生产——也就是真的让它改你的代码、跑你的测试——它会立刻散架。今天我们把那副骨架,长成一副能扛事的身子骨。
先想清楚:生产的循环比 demo 多了哪几样东西?我列四个,你边看边品哪条最要命。
第一,流式。demo 里 model_chat 是一次性返回整段。可真实体验里,模型是一个 token 一个 token 蹦出来的。如果你的 REPL 等模型全想完才渲染,用户盯着空白屏三五秒,体验直接回到上个世纪。所以 Loop 必须把”模型流式吐字”和”模型最终给动作”这两件事拆开:字先往前端流,动作等流完了再解析。
第二,可打断。用户看到 agent 正在疯狂删文件,得能按 Ctrl-C 喊停。demo 那套 for 循环是阻塞的,你按断它就整个崩。生产循环得在每轮动作前检查”用户是不是喊停了”,并且能干净地回到一个安全状态。
第三,终止条件要硬。demo 只有 is_final_answer 和 max_steps。真实里还得有:用户显式说停、成本超预算、连续 N 次工具报错、陷入重复动作(模型在 A→B→A 里打转)。这些都得是显式的”出口”。
第四,错误兜底要分层。demo 里工具报错只是塞回纸条。真实里,报错可能是网络抖(该重试)、可能是参数非法(该让模型换参数)、可能是工具本身崩了(该隔离、别让整个 Loop 挂)。
我给你把生产版主干画出来,注意看我在 1.3 基础上加了哪些钩子:
# 生产型 Agent Loop 主干(示意,非可运行)
def agent_loop(goal, tools, model, context_builder, policy):
state = {"goal": goal, "steps": 0, "last_actions": []}
while True:
# ---- observe:拼纸条(上下文工程层负责"写什么")----
messages = context_builder.build(state) # 系统提示+目标+压缩后的历史+记忆
# ---- think:调模型,流式拿输出 ----
stream = model.chat_stream(messages, tools=tools)
text_buf, tool_call = "", None
for chunk in stream: # 每来一个 token
text_buf += chunk.text
policy.on_token(chunk.text) # 钩子:把字推给 REPL 渲染(流式)
if policy.is_interrupted(): # 钩子:用户按了停?
return halt(state, reason="user_interrupt")
if chunk.is_tool_call: # 模型决定调工具了
tool_call = chunk.tool_call
# ---- act:执行工具 ----
if tool_call is None: # 没调工具 = 这是最终回答
return finish(state, answer=text_buf)
state["steps"] += 1
if state["steps"] > policy.max_steps:
return halt(state, reason="step_limit")
result = execute_tool(tool_call, tools, policy) # 见下:分层兜底
state["last_actions"].append(tool_call.name)
if detect_loop(state["last_actions"]): # 钩子:检测重复打转
return halt(state, reason="action_loop")
# ---- 把动作+结果回流,进入下一轮 observe ----
state["messages"].append(tool_call)
state["messages"].append(result)
# 工具执行的错误兜底(示意)
def execute_tool(call, tools, policy):
try:
return tools[call.name](**call.args)
except RetryableError as e: # 网络抖:按退避重试
return policy.retry(tools[call.name], call.args, e)
except InvalidArgsError as e: # 参数非法:把错误塞回,让模型换参数
return ToolResult(error=str(e), retryable_by_model=True)
except ToolCrash as e: # 工具崩:隔离,不影响 Loop
return ToolResult(error="工具崩溃,已隔离", fatal=False)老哥你盯住 policy 这个东西。它是我特意塞进来的”扩展点集合”——所有那些”生产才需要”的纪律(流式渲染、打断、步数上限、重试、循环检测),全收在 policy 里,Loop 主干保持干净。这又是你后端熟透的手法:把”变化点”抽成策略/接口,主干只写不动的逻辑。你写 Go 时把限流、超时、熔断做成 middleware,和这里把 on_token/is_interrupted/max_steps 做成 policy,是同一回事——主干稳定,变化外挂。
跟你的后端连一下:model.chat_stream 这个流式接口,和你写过的 http.ResponseWriter 写 chunked 响应、或者 gRPC 的 streaming 是一路货。模型这边是”服务端推 token”,你这边是”客户端边收边渲染”。policy.on_token 就是那个 io.Writer 的 Write 回调。区别只在于:HTTP 流的是字节,这里是 token;但”背压、取消、断开重连”那套工程问题一模一样。你当年处理”客户端断开了,服务端别再算了”的 context 取消,正是 is_interrupted() 要解决的——用户 Ctrl-C,得把取消信号顺着 context 传到模型调用那层,别闷头算完一堆 token 又丢掉。
再点一个 1.3 没讲透的:detect_loop 为什么必须有。demo 里我们靠 max_steps 兜底,但 20 步可能模型在第 3 步就”调 A→看结果→又想调 A”了,剩下 17 步纯浪费钱。生产里你会维护一个最近动作序列,发现连续重复(比如 read→read→read 同一个文件、或 edit→edit 互相覆盖)就果断 halt。这其实是把”运维里看监控曲线发现死循环”的智慧,写进了 Loop 本身。
最后给个真实经验:流式 + 可打断 + 终止条件,三者必须配合一个”干净的状态回滚”。用户打断时,agent 可能正改到一半代码。你不能让它停在一个”改了三行但没编译过”的状态就走人——至少得保证”要么全改完提交,要么回到改动前”。这又回到 Part 1 说的”事务思维”:工具的副作用要么成、要么滚。Loop 在 halt 前,得调用工具层的”回滚到检查点”,这正是后面 8.5 端到端设计里要串起来的。
我再给你掰开一个细节,因为”流式”这块最容易被想简单:流式的背压(backpressure)和”思考可见”如何共存。模型吐 token 很快,可你的前端渲染、或者无头模式写日志,可能跟不上。如果 Loop 无脑把每个 chunk 推给 policy.on_token,前端卡住时内存就爆。正确做法是 on_token 走一个带缓冲的异步通道:模型照常吐,渲染端按自己节奏消费,通道满就反压(让模型侧的读取先停)。这和你 Go 里 chan 带缓冲、消费者慢了生产者阻塞,是完全同一套背压机制。更妙的是”打断”也能借这个通道实现:用户按 Ctrl-C,往通道发一个”取消”信号,Loop 在下一个 chunk 处理前检查到它,就停止向模型要后续 token(同时把取消顺着 context 传给模型 API,真·中断推理,省 token)。你看,一个带缓冲的 channel + 一个取消信号,就把”丝滑渲染、可打断、省成本”三件事一次解决了——而这整套,你写过高并发 Go 服务的话,闭眼都能写出来。
还有个 policy 组合的坑值得点:生产里 policy 不会是单个对象,而是一组可叠加的中间件。比如 StreamPolicy(管渲染)、InterruptPolicy(管打断)、BudgetPolicy(管步数/成本)、RetryPolicy(管重试)。它们各自独立、按顺序包裹 Loop。好处是”加新纪律不改旧代码”——要加”每小时成本上限”,就再叠一个 CostPolicy,Loop 主干一行不动。这跟你后端 middleware 链(日志→鉴权→限流→业务)是同一个组合模式。我特别想强调这一点,因为很多初学者会把所有这些”生产纪律”写成 Loop 里的一长串 if,结果 Loop 变成几百行的怪物,改一个策略怕影响另一个。分层 + 策略可叠加,才是让”循环保持纯净”的工程正解。你当年从”handler 里塞满日志鉴权”走到”middleware 链”,今天在 agent Loop 上要走完全相同的那一步。
老哥,我再逼你深想一层”终止条件”——你说 max_steps 兜底够了,墨叔偏说不够,为什么?因为 max_steps 只管”转了多少圈”,不管”转得有没有意义”。模型可能前 5 步就把事干完了,却在第 6 步自言自语、第 7 步又去读个无关文件、磨蹭到 20 步才返回——max_steps 拦得住”超圈数”,拦不住”低效磨洋工”,token 照样烧。所以生产里终止条件是一组,至少四道:圈数(max_steps)、成本(累计 token 花费超预算即停)、停滞(连续 K 轮模型输出几乎无变化、或工具结果无新信息)、目标达成(模型显式 is_final_answer,或外部校验如”测试转绿”通过)。这四道像你后端的”多级熔断”:单靠连接数上限不够,还得有耗时上限、错误率上限、业务正确性校验。只设 max_steps 的 agent,好比只设了”最多处理 1000 请求”却没设”单请求超时”的服务——能防崩,防不住浪费。把这组终止条件收进 BudgetPolicy + HaltPolicy,Loop 主干永远只问”policy 说停吗”,不问”为什么停”。
错误兜底的分层我也得给你一个真实例子,免得你以为 try/except 包一下就完事。假设 run_shell 跑测试,返回”exit code 137”——这是被 OOM killer 杀掉的(内存超了),不是测试真失败。如果 Loop 只做”报错就塞回纸条让模型重试”,模型会一模一样再跑一次,再被 137 杀,死循环到 max_steps。正确的分层兜底:工具层先识别”137 = 资源超限”,返回带分类的结果 ToolResult(error="OOM", kind="resource", retryable=False);Loop 层读到 kind="resource" 且 retryable=False,不盲目重试,而是走”降级”——比如改调一个资源占用更小的命令、或 halt 并提示用户”测试在沙箱里内存不够,请在更强环境运行”。你看,错误”分类”是兜底的命门:不分类,所有错都一个样地重试,必然踩死循环;分类了,Loop 才能”对症下药”(重试 / 换路 / 升级人工)。这和你后端”异常处理要分业务异常、系统异常、可重试异常”是同一套分类学——agent 只是把”异常分类”用在了”模型+工具”这条更长的故障链上。你当年写的 if errors.Is(err, ErrTimeout) 那种精细判断,今天在 execute_tool 里一个不落都得有。
【思考题】
- 我把”流式渲染""打断""步数上限”全收进了
policy,而不是写死在 Loop 主干。如果反过来全写死,后续要支持”无头模式(没人、不渲染、不dialog)“会多痛苦?具体哪几处要改? - 模型是”流式吐 token”的,可”调工具”这个动作往往要等整段想完才能解析。你怎么设计,才能让用户既看到思考过程、又能在模型决定删文件前及时打断?
detect_loop检测”重复动作”,但有些任务本来就要重复(比如”对每个文件都读一遍”)。你怎么区分”良性重复”和”死循环”?
【参考答案】
- 全写死的话,无头模式几乎要重写 Loop:你得把”推 token 给前端”改成空操作、“弹权限确认”改成自动拒绝/自动通过、“读用户 stdin”改成从任务队列取。而这些本该是 policy 的可替换实现——无头模式只是另一个
policy实例(渲染=写日志、打断=永不、确认=默认允许但记审计)。写死后,这些”变化点”和”不变的主干”绞在一起,任何新形态都得动主干,极易引入 bug。这正是后端”不要把日志/监控/限流写死在业务函数里,要做成 middleware/拦截器”的同款教训:可替换性来自”变化外挂”,不是”主干分叉”。 - 关键设计是:流式只负责”展示思考文字”,“执行工具”是另一个必须过的闸门。具体做法是 Loop 在流式中先把”思考文字”推给前端渲染(用户能看到它在想什么),但一旦解析出
tool_call,不立即执行,而是先暂停流、走权限/打断检查(REPL 层弹出”即将删除 X,确认?“或检测 Ctrl-C)。也就是说”看到思考”和”批准动作”是两个阶段——用户能在”它想删”到”它真删”之间插入。这跟你后端”先校验请求、再执行业务”是一个顺序:展示是展示,落库是落库,二者之间有确认点。Claude Code 的丝滑,很大程度是把这个”思考可见、动作可拦”做成了无缝体验。 - 不能只看”动作名重复”,要看”动作对象 + 结果是否推进”。良性重复是
read(file_i)对不同的 i、且每读一个都带来新信息;死循环是read(same_file)对同一对象、且返回和上轮几乎一样(没有新事实)。所以detect_loop该比较的是”动作+参数的指纹”和”结果增量”,而不是单纯计数。更稳的做法是维护一个”近期(动作指纹→结果哈希)“表,若同一指纹连续出现且结果哈希不变,判定死循环;若指纹不同或结果有新内容,放行。这其实借鉴了缓存/幂等判断:同样的请求同样的响应,第二次就值得怀疑。
小结:生产循环 = 1.3 骨架 + 流式 + 可打断 + 硬终止 + 分层错误兜底。所有”生产才需要”的纪律收进 policy,主干保持干净,正是你后端”变化外挂、主干稳定”的老手艺。流式像 chunked 响应、打断像 context 取消、循环检测像运维监控——全是旧酒换新瓶。
8.3 工具集设计:文件读写 + shell 执行
老哥,前面讲 Loop 时我们把”工具执行”当黑盒。今天把这盒子撬开。你造迷你 Claude Code,最核心的工具其实就四类:读文件、写文件、搜代码、跑 shell。前三类 Part 6 也提过是”最核心的 4 类”里的三样,剩下一个搜我们在 8.5 会专门串。这一节先吃透”文件读写”和”shell 执行”这两个最危险也最常用的。
先说工具长什么样。在模型眼里,一个工具不是一段代码,而是一份schema(说明书):名字、一句话描述、参数列表(每个参数的类型、是否必填、取值范围)。模型靠这份说明书决定”调不调、怎么传参”。所以 schema 设计的第一铁律——描述写得好不好,直接决定模型调得准不准。
我给你一个 read_file 和 run_shell 的 schema 示意,你重点看每个字段为什么在那:
# 工具 schema 示意(给模型看的"说明书")
TOOLS = [
{
"name": "read_file",
"description": "读取指定路径文件的全部或部分内容。用于了解代码现状。只读,无副作用。",
"parameters": {
"path": {"type": "string", "required": True, "desc": "文件绝对或相对路径"},
"offset": {"type": "int", "required": False, "desc": "从第几行开始读,默认0"},
"limit": {"type": "int", "required": False, "desc": "最多读多少行,默认200,防一次塞爆窗口"},
},
},
{
"name": "run_shell",
"description": "在受控沙箱中执行一条 shell 命令(如跑测试、grep)。有副作用,需用户确认。",
"parameters": {
"command": {"type": "string", "required": True, "desc": "要执行的命令"},
"timeout": {"type": "int", "required": False, "desc": "秒,默认30,超时强杀"},
"max_output": {"type": "int", "required": False, "desc": "stdout 截断上限,默认2000字符"},
"danger": {"type": "bool", "required": False, "desc": "是否含 rm/mv/网络等危险操作,True 必须二次确认"},
},
},
]老哥你注意 read_file 里的 limit 和 run_shell 里的 max_output——这两个字段是为上下文窗口生的。Part 1 我们说过,工具返回是”往工作台上堆东西”,你得控制每件东西的体积。limit 保证读一个万行文件不会一次吞掉半张桌子;max_output 保证一条命令刷出五千行日志时,只回前两千,剩下的标注”已截断,可再查”。这俩是上下文工程在工具层的”第一道防线”,比事后压缩更便宜。
跟你的 Go 后端连一下:工具 schema 里的 parameters 不就是你 handler 的”入参校验 + swagger 注解”吗?required、type 就是后端接口的参数约束。区别是:后端接口的参数来自”人写的请求”,错了返回 400;agent 工具的参数来自”模型的猜测”,错了概率高得多。所以 agent 工具的 schema 还要多做一步:执行前用 JSON Schema 严格校验模型填的参数,字段类型不对、必填缺失,直接打回让模型重填(呼应 Part 2 的工具失败重试)。你后端靠框架自动校验入参,agent 靠 Loop 里显式的 schema 校验——本质都是”不相信调用方,先校验再执行”。
现在说最刺激的:shell 执行的沙箱与安全边界。老哥,这是整个系统最容易出事故的地方。模型是概率的,你却要允许它执行任意命令——等于把一把没准星的枪递给人。怎么办?三道闸:
第一道,白名单/黑名单。简单粗暴但有效:run_shell 执行前扫描命令,含 rm -rf /、DROP TABLE、curl ... | sh、git push --force 这类,要么直接拒绝,要么强制 danger=True 走二次确认。模型不知道”哪些操作会要命”,你得替它列清单。
第二道,沙箱。命令不该在你本机裸跑。理想是在容器/隔离环境里执行,文件系统挂只读快照或独立副本,网络默认禁掉(除非任务需要)。这样就算模型真跑了 rm -rf,删的也只是沙箱里的副本,你本机安然无恙。这就像装修工人只在”样板间”里施工,动不了你真家。
第三道,权限确认(HITL)。危险动作在 REPL 层弹窗:“agent 想执行 git reset --hard,确认吗?“人拍板才跑。这是概率程序前面那道确定性闸门(Part 1 讲的)。注意确认放哪层——放 REPL 层,不在工具层,理由 8.1 说过:工具层要能在无头模式纯执行。
输出太长怎么办?这是工具层另一个老大难。一条 go test ./... 可能刷出上万行。正确做法不是”全塞回去”,而是截断 + 给信号:返回前 max_output 字符,末尾附一句”输出过长已截断,可指定 -run 某用例 或调大 max_output 再看”。这就把”信息太多”变成一个可再查的钩子,模型下一轮可以主动缩小范围。这其实是个精妙的设计哲学:工具层不替模型做”哪些信息重要”的判断(那是模型的活),工具层只负责”别撑爆窗口,并告诉模型’还有更多,你要就再来’”。
给段真实经验:很多团队一开始让工具”智能摘要”输出(比如自动只回报错行)。这看起来贴心,但有坑——模型可能正需要看那条被你摘要掉的正常日志来定位。所以工具层该做”截断”,不该做”理解性摘要”;摘要是上下文工程层的职责(它有权决定怎么压缩),工具层只管”物理限量 + 留可再查入口”。职责分清,模型才能拿到完整事实自己判断。
老哥,schema 里最容易被你低估、却最决定成败的,是 description 那一句话。墨叔得单独点一下这门手艺:描述不是写给人看的文档,是写给你那个”会一本正经胡说”的实习生看的指令。模型选工具、填参数,全靠读这段描述来”猜”你的意图。描述写得模糊,它就瞎调;写得精确,它十拿九稳。给你几条血泪准则。其一,讲清”什么时候该用、什么时候别用”,比讲清”它干嘛”更重要。比如 run_shell 的描述里写”用于跑测试或一次性查询;不要用它做可用 edit_file 完成的文件修改”——这一句能拦掉一半误用。其二,把隐含前提写进去。比如 read_file 写”只读、无副作用、可安全反复调用”,模型就知道不用担心调用代价。其三,参数描述要给”取值范围和反面教材”。比如 offset 写”行号从 0 开始;不要传负数”。你写 Go 时 swagger 注解是给同事和代码生成器看的,语义宽松点没事;agent 工具描述是给”不懂你业务、但语文极好”的模型看的,它专挑字面缝隙钻——你少写一个边界,它就踩一个坑。这门”给概率调用方写精确指令”的手艺,是传统后端没有、但 agent 工程里极值钱的软技能。
再掰一层沙箱的具体形态,免得你停在”用容器”三个字。一个务实的迷你沙箱,从上到下可以这么叠:文件系统层,把项目目录挂成”写时复制(copy-on-write)“的快照,agent 改的都是副本,你本机原目录纹丝不动,确认无误再 rsync 回去;网络层,默认DROP所有出站,只有显式声明的”允许访问内网 npm 源/私有仓库”才放行,防它偷偷 curl 外网乱下载;资源层,用 cgroup/容器限额锁死 CPU、内存、进程数、且 timeout 参数对应到 SIGKILL 强杀。这三层合起来,即使模型生成了”删光所有文件”的命令,它删的也只是那份 COW 副本,你的真实仓库在沙箱外安然无恙。这和你后端做”多租户隔离”用的是同一套思路:不靠调用方善良,靠环境让它作不了恶。很多团队一开始觉得”沙箱太重、先裸跑”,结果第一次模型手滑就删了 .git——这笔学费太贵,沙箱是迷你 Claude Code 的”安全带”,不是可选项。
截断策略我再补一个工程细节:截断不能”静默砍”,必须在返回里带上可机读的信号,而不只是给人看的提示。比如返回结构写成 ToolResult(content=前N字符, truncated=True, total=全文长度, hint="用 grep_in_file 按关键字再查"),其中 truncated 是布尔字段、hint 是给模型下一轮的行动建议。模型读到 truncated=True 就知道”信息不全、别急着下结论”,而不是傻乎乎基于前 N 行就拍板。这把”输出太长”从一个”丢信息事故”变成一个”可恢复的状态”——模型可以主动决定”我要的是后半段,再查”。你后端也有同款:分页接口返回 has_more=true + next_cursor,调用方据此决定是否翻页。截断 + truncated 信号,就是工具层的”分页协议”,模型是那个会自己翻页的调用方。
老哥,工具 schema 还有个你容易忽略的工程细节:版本与演进。你上线的工具有一天要改参数(比如给 run_shell 加 cwd 字段),模型可能还在按旧 schema 调。后端接口你用 URL 版本号(/v1/...)或 protobuf 兼容规则管,agent 工具也得有类似纪律:新增参数永远 required=False(老模型不传也不崩),改语义等于”换工具名”(如 run_shell_v2),绝不在原地改必填项。因为调你工具的不是人、是个会”照旧记忆调”的模型——它上次见过 run_shell 长那样,你偷偷改了必填项,它就稳定报错。这跟你后端”对外部调用方保持接口兼容”是同一份谨慎,只是外部调用方变成了概率模型,记忆更顽固、容错更低。所以工具的 schema 一旦发布,就当它”有契约”——变更走版本,别走原地改。这也是为什么前面说工具要像 repo 接口那样被严肃对待:它是 agent 系统里最该”契约化”的边界之一。
【思考题】
read_file的limit默认 200 行,如果模型要读懂一个 2000 行的核心文件,它得调 10 次。你怎么设计让它”高效读长文件”而不浪费 10 轮?提示:想想你后端怎么分页/怎么取概要。- 白名单拦
rm -rf /容易,但模型可能写出find . -name '*.go' -delete这种”看起来正常但破坏力等同”的命令。纯字符串黑名单为什么兜不住?你有什么更稳的思路? - 工具层做”截断”但不做”摘要”,如果上下文工程层也做压缩,两者会不会重复劳动?边界怎么划?
【参考答案】
- 学你后端的”列表接口”思路:给
read_file加一个”概要模式”——比如read_file(path, mode="outline")只返回函数签名、结构体定义、 import 这几样”骨架”,不返回实现细节。模型先 outline 拿到全局地图,再针对性read_file(path, offset, limit)读它关心的那几百行。这相当于”先拉索引、再拉明细”,把 10 轮降到 2 轮。更进一步可加grep_in_file(path, pattern)让它直接搜行号。核心思想:工具要提供”由粗到细”的读取粒度,别只给”全量”一个档。你后端分页、游标、字段投影,全是同一套”按需取量”的哲学。 - 纯字符串黑名单必然漏——模型能用无数种等价写法达到破坏效果(
mv、dd、:(){ :|:& };:fork 炸弹、甚至python -c "import os;os.remove(...)")。更稳的思路是沙箱 + 权限最小化:不在”拦命令”上死磕,而在”限制后果”上下注。让命令在只读文件系统副本 + 禁网 + 资源限额(CPU/内存/进程数)的容器里跑,破坏力天然封顶。再叠加”最小权限原则”:agent 用的账号只对该项目目录有写权,对系统目录无权限。一句话:宁可让危险命令在无害环境里跑,也别指望拦住所有危险写法。这和你后端”不靠输入过滤保安全,靠最小权限 + 沙箱”是一个层次的安全观。 - 不重复,因为层级不同、目的不同。工具层截断是”物理限量”:它在产出那一刻就砍到
max_output,是为了保护窗口不被单次返回撑爆,是无脑的、确定的(前 N 字符)。上下文工程层压缩是”语义提炼”:它拿到已经被截断/完整的多个返回后,根据”当前目标”决定哪些该留全貌、哪些该压成摘要、哪些可丢,是带意图的、依赖上下文的。边界划在:工具层只回答”这次返回多大我放不放行”,上下文层回答”这些返回里哪些对当前任务还有用”。一个管”量”,一个管”用”。就像后端里”网关限流”和”业务层缓存策略”——都涉及”少传点数据”,但一个防压垮、一个提效率,不冲突。
小结:工具 = 给模型的 schema 说明书,描述好坏决定调用准度;limit/max_output 是上下文窗目前的第一道防线;shell 执行靠白名单 + 沙箱 + HITL 三道闸;工具层只做”截断 + 留可再查入口”,不替模型做摘要。这全是后端”入参校验、最小权限、限流”的平移,只是调用方从人变成了概率模型。
8.4 上下文管理:把长对话塞进窗口
老哥,这一节是前面 Part 1(上下文窗口)和 Part 4(记忆)的落脚点,也是整个迷你 Claude Code 能不能”干长活”的关键。前面我们反复念叨”窗口是张有限的桌子”。可真实改 bug 的任务,聊个二三十轮、调几十次工具,返回的文本加起来能顶好几个窗口。怎么办?答案不是”换更大的桌子”(模型窗口再大也有限、且越大越贵越易失焦),而是主动编辑这张桌子上的内容。
这门手艺叫上下文工程,核心就一句话:什么必留、什么可丢、什么该压。我先给你这三类的判定尺,再讲三种具体手法。
必留的:用户的总目标(“修掉 issue #123 的空指针”)、当前的任务状态(进行到第几步、卡在哪)、模型刚做的”决策与理由”(为什么选 A 不选 B,防止它下一轮推翻自己)、以及最近一两轮的工具结果(刚读到的代码,马上要用)。这些东西丢了,agent 就”断片”——忘了要干嘛、或重复劳动。
可丢的:早已消费完的旧工具返回(比如三圈前读过、且已据此改完的文件,没必要再占着桌子)、重复的确认对话、已经被压缩进摘要的早轮细节。它们”当时有用、现在没用”。
该压的:长对话里那些”有信息量但不必逐字保留”的,比如十轮前的讨论,压成三句话摘要(“已定位问题在 auth.go,确认是 nil map,正在改”)。压完体积变小,但关键信息还在。
我给你三种工程手法,从简单到进阶。
手法一,滑动窗口(Sliding Window)。最朴素:只保留最近 N 轮对话进窗口,更早的整段丢弃或外移。优点是实现简单、绝对不超窗;缺点是”丢了就真丢了”,如果早轮有个关键事实(比如用户说”别动 config 目录”),滑出去后就失踪。所以它常配合”必留项钉死”——把系统提示里的”硬约束”和”总目标”永远钉在窗口头部,不参与滑动。
手法二,摘要压缩(Summarization)。当窗口快满,把最老的一批对话交给模型(或规则)压成摘要,替换原文。示意:
# 上下文压缩示意(伪代码)
def compact(messages, model, keep_head=4, keep_tail=6):
# 头:系统提示+目标,永远留;尾:最近几轮,留全貌
head = messages[:keep_head]
tail = messages[-keep_tail:]
middle = messages[keep_head:-keep_tail]
if middle:
summary = model.summarize(middle) # 调一次模型,压成摘要
middle = [{"role":"system","content":f"[早期对话摘要] {summary}"}]
return head + middle + tail老哥你看,keep_head 把”总目标/约束”钉死,keep_tail 把”眼前的事”留全,中间那段没那么急的压成摘要。这就像你开会:老板交代的”死规矩”记在笔记本第一页(头),正在谈的细则记详细(尾),上周讨论过的旧方案只留一句”已否决,原因 X”(摘要)。你后端也有同款——日志的”热数据留库、冷数据归档压缩”,一模一样。
手法三,关键事件保留(Key Event Retention)。这是 Claude Code 们真正用的高级手法:不在”轮次”上切,而在”事件”上切。定义几类高价值事件——目标设定、决策(含理由)、工具结果中的”结论性片段”(如”测试失败:第 12 行 panic”)、用户纠正(“不对,别动那个文件”)——把这些事件结构化存起来,作为”骨架”永远留在窗口;而工具返回的原始长文本、思考过程草稿,作为”血肉”可丢可压。这样即使丢了血肉,骨架还在,agent 不会”失忆”。
跟你的 Go 后端连一下,这三手法全有影子:滑动窗口=你服务的”只保留最近 N 条活跃连接/最近 N 条日志”;摘要压缩=日志的”聚合归档”(把一堆 detail 行合成一条 summary 入库);关键事件保留=事件溯源(Event Sourcing)里你只存”领域事件”不存中间状态。你当年用 ES 时,就是”把发生过的决策性事件永久留着,过程数据可扔”——agent 的关键事件保留正是这个思路,只不过”事件”是模型自己产生的。你看,又是旧酒:你早就在用”保留决策、丢弃过程”管理状态了。
我给你个真实经验层面的坑:压缩本身会丢信息,而且压缩可能压错重点。用模型压摘要时,它可能把”用户说别动 config”这种硬约束压没了,因为它觉得”不像重点”。所以安全的做法是——硬约束不走压缩通道,永远钉在系统提示固定段;只有”软性的讨论过程”才进压缩。另外,压缩是”再调一次模型”的开销,别每轮都压,设个阈值(比如窗口用到 80% 才压一次)。这和你后端”别每次请求都查慢查询做聚合,扛到阈值再批处理”是一个省成本的直觉。
最后呼应一下 Part 4 的记忆分工:窗口(短期记忆)放”马上要用的”,外部存储(长期记忆)放”以后可能用、但现在别占桌子”的。比如用户偏好(“用 tabs 不用 spaces”)、项目约定,进长期记忆,窗口满了从那里重新注入。上下文工程层每轮要干的,就是”从长期记忆取 relevant 的、从窗口留关键的、把旧的压掉”——这正是 Part 4 说的 RAG 思路在”自身对话”上的应用:agent 在检索”自己刚才聊了啥”。
老哥,三种手法不是”选一个用”,是”叠着用、各管一段”。我给你一张务实的取舍表,免得你以为压缩万能。滑动窗口胜在零成本、绝对不超窗,败在”丢了就真丢”;所以它管”最近 N 轮”这种不怕丢的。摘要压缩胜在”保信息量”,败在”再调一次模型有开销、且可能压错重点”,所以它管”中间那段不急但要留个印象的”。关键事件保留胜在”决策不丢”,败在实现最重(要定义事件、要结构化存储),所以它管”目标/决策/报错/用户纠正”这些命门。生产系统里这三样是同时在线的:头部钉死(关键事件里的硬约束)+ 尾部全留(滑动窗口的近 N 轮)+ 中间压缩(摘要)+ 全程事件溯源(关键事件库)。你后端做缓存也是这路数:L1 本地热数据、L2 分布式、L3 落盘冷存,各层管各的命中率——上下文管理的”多层”和你缓存的”多级”是同一个工程直觉,只是缓存的对象从”key-value”变成了”对话片段与事件”。
我带你走一遍具体的压缩演练,把抽象的”压”变成你能看见的动作。假设窗口已用 85%,该压了。上下文工程层取出 messages[keep_head:-keep_tail] 这段(比如第 5 到 20 轮),里面混着:两轮 read_file 返回(长)、三次 edit_file 前后对比、两段模型思考。压缩时,它不是无脑喂给模型说”总结一下”,而是按”事件类型”区别对待:两个 read_file 的长返回,本就已在外部记忆存了全文,这里只留”已读 auth.go:1-80,核心是第 42 行 nil map”一句指针,原文转外部存储(不压、直接丢窗口副本);三次 edit 压成”共修改 3 处,均在 auth.go,目的:初始化 map 防 nil”;两段思考压成”判断根因是未初始化,非并发问题”。压完,原本占 6000 token 的中段变成 400 token 的摘要。模型下一轮读到这段,既知道”改了啥、为什么”,又不占桌子。注意关键:read_file 的原文走”外部化”而非”摘要化”——因为原文是客观事实,压成摘要可能漏细节,而原文在外部随时可 read_file 取回,所以窗口里只留”指针+结论”。这又是”短期放指针、长期放全文”的分工。你后端把大对象存 OSS、DB 里只留 URL,是同一个取舍。
最后点一个容易被忽视的风险:压缩不是免费的,而且压缩会放大”lost in the middle”。模型对”窗口正中间”的内容注意力最弱(Part 1 提过)。如果你把最重要的”当前目标”不小心压到了中段,模型反而抓不住。所以压缩后必须做一次”重排”:把 keep_head(目标/约束)永远钉在最前,把”当前进行中的任务状态”钉在最后(紧挨模型视线),中间才放压缩摘要。换句话说,压缩解决”量”,重排解决”注意力分配”——两者缺一不可。这也解释了为什么上下文工程值得单独成层:它干的不是”砍字”,是”在有限且注意力不均的窗口里,把对的话摆在对的位置”。这门手艺的精细程度,直接决定你的迷你 Claude Code 是”聪明”还是”健忘”。
老哥,我给你一个能立刻上手的”最小可用上下文策略”,你照着排就能避掉八成坑:第一,系统提示固定三段——身份与纪律、总目标(每轮重写、随用户变更更新)、硬约束(“别动 config、用 tabs”这类永久钉死);第二,尾部永远留最近 4 轮全貌,保证”眼前事”模型看得清;第三,超过窗口 70% 才触发一次压缩,别每轮压(省模型调用钱);第四,压缩只压”中间轮”,且 read_file 长返回走”外部化”(留指针)而非”摘要化”;第五,每次压缩后做重排,目标在头、当前状态在尾。这五条就是你上下文工程层的”默认实现”,跑通后再按需调阈值。你后端做缓存也有类似的”默认 TTL、默认淘汰策略、热点保活”——上下文管理的”默认策略”同样是先有个能用的基线,再据实测调。别一上来追求完美压缩算法,先把”头钉死、尾留全、中间压”这条基线立住,你的 agent 就不会失忆,剩下的都是调优。
【思考题】
- 滑动窗口把”三圈前读过的文件”整段丢了。如果 agent 下一轮突然需要回忆”那个文件第 50 行长啥样”,丢了就傻了。你怎么补这个洞,而不放弃滑动窗口的简单性?
keep_head钉死”总目标”,可如果任务进行中用户改了目标(“不修空指针了,先加个日志”),钉死的头该怎么更新才不矛盾?- 关键事件保留里,你怎么定义”什么是高价值事件”?给模型自己判断,还是你写死规则?各有什么坑?
【参考答案】
- 补洞靠”外置冷存储 + 可再查”,而不是把旧内容硬留在窗口。具体:工具层调
read_file时,把”读过的内容”顺手写进一个外部记忆(或本地文件缓存),并在窗口里只留一句”[已读 auth.go,共 800 行,需回顾可调 read_file 重读]“。窗口丢了原文不要紧,因为原文在外部还在,且工具能随时取回。这恰恰是把”短期记忆(窗口)“和”长期/外部记忆”分工用上:窗口放”指针和结论”,外部放”原文”。你后端也是这么干的——内存里只留热点数据,全量在 DB,要就查。别试图把所有历史塞进一张桌子,桌子是给你”正在用的”准备的。 - 目标变更要”显式更新 + 保留旧目标痕迹”,不能默默覆盖。做法是:系统提示里维护一个”当前目标”字段,用户改目标时,Loop 把它更新,并在上下文里追加一条事件”用户于第 N 轮将目标从 X 改为 Y,原因 Z”。这样模型既看到”现在要干 Y”,也看到”之前是 X、为什么换”——防止它基于旧目标继续行动。这对应你后端”状态变更要有审计日志”:直接
goal = new是埋雷,goal变更记一笔事件才是靠谱做法。关键事件保留里的”目标设定/变更”正是为高价值变更留痕设计的。 - 写死规则更稳,模型判断更易漏。写死规则(如:凡
user消息里的否定句、凡工具返回的”error/fail/panic”、凡模型自述”我决定做 X 因为 Y”)标记为关键事件,可控、可复现、不依赖模型当次发挥。坑是规则可能漏掉”没长成标准样子的关键信息”。让模型判断则灵活,但模型可能把废话当重点(尤其压缩时),且每次判断不一致、难调试。工程上推荐规则为主 + 模型为辅:用确定规则抓”明显的决策/报错/用户纠正”,用模型只在”边界模糊”时辅助标注。这又回到 Part 1 心法——“信任流程不信任输出”:关键事件的抓取是流程该硬编码的确定性环节,不该交给概率的模型临场发挥。
小结:上下文管理 = 判定”必留/可丢/该压” + 三手法(滑动窗口、摘要压缩、关键事件保留)。硬约束钉死在头、近期留全、中间压缩;关键事件保留像事件溯源只存决策性事件。窗口放马上用的、外部存以后用的——和你后端”热数据留内存、冷数据归档、状态变更记事件”是同一套旧酒。
8.5 让它真正能改一个 bug:端到端设计
老哥,前面四节我们把五层、循环、工具、上下文各自拆明白了。但拆开的东西不串起来,你还是造不出能用的东西。这一节,墨叔带你把前面所有机制,串成一条从”收到一个 issue”到”产出 PR”的流水线。你盯着这条线看,会发现自己其实在设计一条”后端 pipeline”——只不过每个节点里,“决策”是模型出的。
先定场景:你给 agent 一个 GitHub issue:“登录后偶发 500,疑似 auth.go 里 nil map”。目标是让它定位、修复、跑测试、提交 PR。我把端到端信息流画出来,每一跳都标清楚”哪层在干活、信息怎么流”:
[用户输入 issue]
→ REPL层:渲染、确认"允许 agent 动代码库"
→ 上下文层:把 issue 写进 messages 头部(总目标钉死),注入"项目约定"长期记忆
→ Loop层:observe(看目标)→ think("要先定位相关文件")
→ 工具层:search_code("nil map", path="auth.go") ← 搜
→ 工具结果回流:Loop 拿到"auth.go:42 处 m[role] 未初始化"
→ 上下文层:把这个结果作为"关键事件"保留
→ Loop层:think("读 auth.go 上下文确认")
→ 工具层:read_file("auth.go", offset=30, limit=40) ← 读
→ 工具结果回流:看到函数体
→ Loop层:think("在 42 行前初始化 map")→ act
→ 工具层:edit_file("auth.go", old, new) ← 写
→ 工具层:run_shell("go test ./auth/...", timeout=60) ← 跑测试
→ 工具结果回流:测试通过 / 或失败(失败则 Loop 带着报错再 think,自修正)
→ Loop层:think("改完且测试过,提交")
→ 工具层:run_shell("git add . && git commit && git push") ← 提(走HITL确认)
→ REPL层:展示 diff,等人 review/merge
老哥你逐跳看,是不是每一节讲的东西都在里面当了零件?我挑几个”串接点”给你点透,这些是设计时才想得明白、写时才不会乱的地方。
串接点一:搜是”读”的前置,读是”写”的前置。 你不能让模型一上来就 edit_file——它还没读过代码,改哪都不知道(Part 1 说的”只行动不推理”幻觉)。所以流水线强制”搜→读→写”的顺序,而这顺序不是写死的,是模型在每轮 think 里”基于已得事实”自己推出来的。这正呼应 ReAct:事实喂养推理,推理定向行动。你的 Loop 不用硬编码”必须先搜”,你只需保证工具返回的事实回流,模型自然会走出这顺序——你设计的是”能获取事实的循环”,不是”写死步骤的脚本”。这区别很重要:写死步骤是传统 pipeline,让模型决策顺序是 agent。
串接点二:工具结果回流是整条线的血液。 8.2 说过”结果不回流 = 致命”。在端到端里更明显:搜到的行号不回流,模型就没法 read_file 那一行;测试失败信息不回流,模型就没法自修正。所以上下文工程层在这里的活儿是——把每次工具结果,按”关键事件保留”规则,决定全留(如测试报错)、压缩(如长日志摘要)、还是转外部存储(如读过的全文)。一条流水线顺不顺,全看”血”回得及不及时、准不准。
串接点三:自修正靠”测试结果是硬事实”。 模型改完代码,如果 Loop 不跑测试、只看模型自己说”我改好了”,那就是让瞎子验收瞎子(Part 1)。所以流水线必须在写之后插一个”跑测试”的工具调用,把客观结果回流。测试挂了,模型看到红色报错,下一轮 think 就被拉回地面重新改——这就是”基于环境的纠错”在真实任务里闭环。你后端 CI 也是这个理:代码推上去先跑测试,红了就打回,人是靠”红绿信号”纠错的,agent 同理,只是把”人看 CI”换成”Loop 看工具返回”。
串接点四:危险动作在流水线末端走 HITL。 注意 git push 那一步——它在流水线最后,且必须过 REPL 层的权限确认。为什么放最后?因为前面搜/读/写/测都是”在沙箱/本地试错”,错了能回滚;而 push 是”把改动推给全世界”,不可轻易撤。所以流水线的安全边界是**“本地试错自由、对外发布确认”**。这和你后端”预发环境随便造、生产发布要审批”完全同构。Loop 在 halt/提交前,调工具层的”回滚到检查点”,保证”要么全成、要么回到改动前”——呼应 8.2 说的状态回滚。
跟你的 Go 后端连一下,这条端到端流水线,本质就是一个带 LLM 决策节点的数据处理 pipeline:输入(issue)→ 若干 stage(搜/读/写/测/提)→ 输出(PR)。每个 stage 是”确定性工具”,stage 之间的”走哪个 stage、传什么参数”由模型在 Loop 里决定。你写过的”ETL 管道""工作流引擎”,骨架一模一样;区别只是传统管道里 stage 顺序是 DAG 写死的,agent 管道里”下一步走哪”是模型按上下文临时定的。你甚至可以把”工具层”直接想成你 pipeline 里的”worker 池”,把 Loop 想成”调度器”——只不过调度器的路由逻辑是个会读自然语言的概率模型。
给段真实经验收个尾:这条流水线最容易被低估的环节是**“提交前的自检”**。很多 demo 改完代码就直接 commit,结果把”编译不过”或”改了 A 却破了 B”的半成品推上去了。生产做法是在 commit 前强制跑一遍”构建 + 受影响范围的测试”,且把”改动 diff”先回喂给模型让它自己 review 一遍(“你改的这两行会不会影响隔壁调用方?”)。这一步叫 agent 自审(self-review),是 8.2 里”反思循环”在末端的落地。省掉它,你的 PR 质量会断崖下跌——这和你们团队”提交前自己过一遍 diff”是同一份工程纪律,只是执行者从人变成了 agent。
我给你一个 self-review 的具体对话样例,让你看见它怎么落地。commit 前,Loop 把”本次 diff”作为一条独立消息塞回模型,并附指令”你是资深 reviewer,请只指出会导致回归或引入新 bug 的问题,没问题就说 LGTM”。模型回:“第 47 行你初始化了 map,但第 60 行有个提前 return 分支没走到初始化,仍可能 nil——建议把初始化提到函数开头。“Loop 读到这条 critique,带着它重新 think,再去 edit_file 把初始化提前,再跑测试。这一来一回,就是 8.2 说的”反思循环”在末端闭环。注意它和”跑测试”的区别:测试是客观硬事实(红/绿不赖你),self-review 是主观软检查(模型可能也漏看)。所以 self-review 永远排在测试之后、作为”再保险”,不能替代测试。你团队 code review 也是这定位:CI 过了才给人 review,人 review 兜 CI 兜不住的”该不该这么改”。agent 把这套一并自动化了。
老哥,这条流水线我还得给你泼个现实的冷水:端到端最容易死的,不是单个工具,而是”阶段之间的衔接”。我列三个真实会发生的衔接故障,你设计时就得预埋对策。故障一,搜不到。issue 说”登录偶发 500”,可代码里压根没有”登录”两个字(用的是 session.issueToken),search_code("登录") 空手而归。对策:搜失败不能让 Loop 停,而要触发”换关键词再搜”或”让模型读目录结构猜模块”——把”搜不到”当成一条普通事实回流,模型自己换路。故障二,读到的不是最新的。模型读了缓存里的旧文件,改完一跑测试和”它以为的”对不上。对策:工具层读文件时带版本/时间戳,且 edit 后强制 invalidate 该文件的上下文缓存(呼应 8.4 外部存储的”原文”要和磁盘同步)。故障三,改 A 破 B 但测试只覆盖了 A。模型自测”绿了”就自信提交,结果 B 崩了。对策:提交前跑”受影响范围的更广测试”而非仅单测,且 self-review 专门盯”改动是否波及未测路径”。这三个故障的共同点是:它们都发生在”工具返回 → 模型据此决策”的接口处,印证了开篇和 Part 1 反复说的——agent 最脆弱的就是”纸条写得准不准、事实回得全不全”。你后端 pipeline 里”stage 间数据契约对不对”也是同样的命门,只是 agent 这边契约的一方是个概率模型,所以你得在每道接口加”事实校验 + 失败可恢复”的缓冲。
最后把这条线和你后端彻底对齐收个口。你写一个”接收 webhook → 调 N 个内部服务 → 落库 → 发消息”的工作流,骨架是:入站消息 → 校验 → 路由到各 worker → 聚合结果 → 出战。迷你 Claude Code 的端到端线,骨架一字不差:issue(入站)→ 上下文工程层校验并钉目标(校验)→ Loop 路由到搜/读/写/测各工具(worker)→ 工具结果聚合进上下文(聚合)→ 产出 PR(出战)。唯一不同:你的 worker 路由表是写死的 DAG,agent 的”下一步走哪个 worker、传什么参”是 Loop 里模型按上下文临时定的。所以你造 agent 时,真正要新学的只有一件事——“把路由决策交给一个会读自然语言的概率节点”,其余的校验、聚合、失败处理、可观测,全是你做 pipeline 时烂熟于心的旧货。这,就是墨叔整本教程想让你带走的那个底:你不是从零学新范式,你是在给老架构换了个会聊天的调度器。
【思考题】
- 流水线里”搜→读→写→测→提”的顺序,我说”不是写死、是模型推出来的”。但如果模型第一轮就非要直接
edit_file(幻觉式自信),Loop 该怎么拦,而不是放任它改一个它没读过的文件? - 自修正靠”测试结果是硬事实”,可有些 bug 没有可跑的测试(比如一个偶发的并发竞态)。模型改完”觉得对了”但没硬事实验证,你怎么给这条流水线补一个”验证闸”?
- 把这条流水线类比成你后端的”工作流引擎”,模型相当于引擎里的哪一部分?如果模型这一步”决策慢/抽风”,引擎整体会怎样?你有什么工程兜底?
【参考答案】
- 不能只靠”信任模型不瞎改”,要在工具层加前置约束:
edit_file执行前,检查”目标文件近 N 轮是否已被 read_file 读过 / 是否在上下文里存在内容指纹”。没读过就拒绝执行,返回ToolResult(error="修改前必须先 read 该文件", retryable_by_model=True),逼模型先去读。这是把”安全边界”做进工具契约,而不是指望模型自觉——呼应 Part 2”副作用与安全”。就像你后端”写库前必须先校验资源存在”,是确定性的前置检查,不是靠调用方自觉。模型被拒一次,看到报错自然改走”先搜后读”。流程框住概率,老话重提。 - 没有可跑测试时,补”替代性硬事实”:一是让模型自跑一个复现脚本(写个最小用例触发竞态,改完再跑看是否还触发);二是静态校验(编译、lint、类型检查,这些是确定性的硬信号);三是把 diff 回喂模型做 self-review,让它找”是否引入新风险”——虽然后者仍是概率的,但叠加前两者就够稳。更稳妥是引入人工确认闸:无硬事实验证的改动,push 前强制 HITL,让人拍板。原则不变:能拿到客观信号就用客观信号(测试/编译/lint),拿不到就升级到人。你后端对”无法自动验证的变更走人工 review”也是同款。
- 模型相当于工作流引擎里的**“路由/决策节点”(router)——传统引擎里是
switch/DAG 配置决定下一步,agent 里是模型读上下文决定。模型慢/抽风,引擎整体就”卡在路由那一步”:要么等它吐词(延迟高),要么它抽风给出非法路由(工具层 schema 校验拦下、打回重决策)。工程兜底有三:一是超时与重试**(模型调用超时走退避重试,见 8.2policy);二是降级路由(模型连续失败 N 次,Loop 切到”保守模式”——比如只做只读分析、不自动改,等人工介入);三是把决策结果持久化,模型抽风不影响已完成的 stage,重跑可从检查点续。你看,又是你后端”外部依赖慢/不稳时”的老三样:超时、重试、降级——agent 只是把”外部依赖”从 DB 换成了模型。
小结:端到端 = 把五层与循环、工具、上下文串成”issue→搜→读→写→测→提→PR”的流水线。关键串接点:事实喂养顺序(非写死)、结果回流是血液、硬事实自修正、末端 HITL。它本质是你后端”带 LLM 决策节点的 pipeline”,模型是路由节点,超时/重试/降级照旧。你不是在设计新物种,是在给老 pipeline 换了个会读人话的调度器。