跳转至

Agent 构建指南:研发要素全景

本章不对应原站文章,是知识库的补充章节(独立原创):把第 2 部分拆过的零件和开源 Agent 的做法串成一张全景图。

一句话结论:一个生产级 Agent = 一个「调模型 → 用工具 → 再调模型」的循环,加上围绕它的五层工程模块和一条贯穿各层的治理线。循环决定 Agent 能做什么;模块决定它能不能在多用户、多实例、随时会出错的环境里可靠地做完。先把循环跑通,再按风险逐层补模块。

拆了这么多零件,自己造一个时从哪下手?

前面几章把 Claude Code 和 Codex 拆得很细:System Prompt、文件读写、上下文压缩、记忆、权限检查、Plan Mode、Harness。

你可能会问:如果明天要我从零做一个 Agent 产品,这些零件哪个先做、哪个可以晚点做?有没有漏掉的?

拆解是按「这个工具有什么」组织的,造东西要按「请求经过哪些层、每层会出什么事故」组织。这一章换个角度,把零件重新排一遍。依据有两类:公开的工程文章,以及 9 个开源仓库的源码(以 Codex、OpenHands、Cline、opencode、Cloudflare Agents 等 Agent 框架为主,另有续传库和沙箱平台)。文中的「常见的坑」是做 Agent 时很容易踩到的通用问题。

全景:五层模块加一条治理线

先看全貌。五层从上往下是一次请求经过的层次;单独的那一组「治理线」不属于某一层,而是横跨所有层。

交互层编排层上下文层能力层状态层治理线(横跨各层)帧协议与能力协商流式输出与权威全文断线续传与多端同步Agent 循环(内核)多 Agent 编排执行模型:串行 · 中途输入 · 取消提示词装配与缓存历史压缩记忆与检索工具与代码闸门技能打包沙箱与出网代理会话与对话记录任务实体与事件流冷热分层与单写者安全与权限人工确认可观测与 trace评测与回放配置与灰度发布
交互层编排层上下文层能力层状态层治理线(横跨各层)帧协议与能力协商流式输出与权威全文断线续传与多端同步Agent 循环(内核)多 Agent 编排执行模型:串行 · 中途输入 · 取消提示词装配与缓存历史压缩记忆与检索工具与代码闸门技能打包沙箱与出网代理会话与对话记录任务实体与事件流冷热分层与单写者安全与权限人工确认可观测与 trace评测与回放配置与灰度发布

加粗的「Agent 循环」是唯一一个所有 Agent 都必须有的模块,其余模块按风险逐步补。哪些现在就要、哪些可以晚做:

模块 它解决的问题 原型阶段 上线前
Agent 循环 让模型连续调用工具直到完成,并在该停的时候停下 必须 必须
工具与闸门 模型能做什么,哪些事绝对不能做 必须 必须
提示词装配与缓存 每轮给模型看什么,成本和延迟能不能接受 简单版 必须
历史压缩 / 记忆 对话变长后不超窗口、不丢关键信息 可以晚做 长对话必须
多 Agent 编排 任务太重或需要隔离时分给执行者 先不做 按需
执行模型 同一会话不并发乱写;中途来新消息、取消、崩溃都有确定行为 单进程可省 多实例必须
沙箱与出网代理 执行任意代码而不泄露凭据、不被注入攻击利用 执行代码就要 必须
交互协议与续传 前端稳定展示流式内容、任务进度和交互卡,断线不乱 简单版 必须
状态与持久化 记录能查、能恢复,热数据快、冷数据全 简单版 必须
治理线 出问题能定位,改提示词能评估,发布能回滚 先接 trace 必须

回到主线:这张图里除了循环,每个模块都在回答同一个问题:在有限的上下文和不可靠的运行环境里,怎么让模型「看到的」是对的、「做出的」是安全的、「说出去的」能可靠送达。

下文按「内核 → 工程化 → 治理」展开:内核(循环、工具、上下文)决定 Agent 聪不聪明,工程化(编排、执行模型、安全、交付)决定它可不可靠。状态层没有单独成节,它的内容分散在执行模型和交付两节里。

内核一:循环最难的是什么时候停

第 3 部分的 Coding Agent 已经写过循环本身,十几行代码。生产问题几乎都出在终止和中断上。

输入组装上下文流式调用模型有工具请求?完成:先落盘,再发最终全文执行工具(可并行,失败也回结果)结果回填历史护栏:迭代上限 → 收口总结软预算 → 不再开新一圈硬超时 / 用户打断 → 取消并补齐半截工具调用 否是下一圈 每圈检查
输入组装上下文流式调用模型有工具请求?完成:先落盘,再发最终全文执行工具(可并行,失败也回结果)结果回填历史护栏:迭代上限 → 收口总结软预算 → 不再开新一圈硬超时 / 用户打断 → 取消并补齐半截工具调用 否是下一圈 每圈检查

几条做法:

  • 设三道上限,各管一件事。 迭代次数(例如几十圈)防死循环;软预算(例如十来分钟)只拦「再开一圈」,不打断正在进行的调用;硬超时(再长一些)兜底取消一切。三者分开,体验和资源保护才能兼顾。
  • 撞上限时收口,而不是报错。 到达迭代上限后再调一次模型,但禁止它再用工具(tool_choice 设为 none),让它用现有信息写总结,并把结束原因(如 max_iterations)告诉调用方。用户得到的是「做到哪了」,而不是一条错误。
  • 半截工具调用必须收拾。 被打断时,已经发出但没拿到结果的工具调用会让下一轮的历史不合法。两种主流做法:Codex 给它补一条合成的 "aborted" 结果,Cline 把不完整的调用整段丢掉。选哪种都行,但必须选一种。
  • 先落盘,再发「完成」。 否则前端一刷新,刚才那轮就不见了。
  • 流式文本要有权威全文校准。 逐段下发的文字可能丢片段,也可能被服务端事后清理(去掉半截链接、重试残留)。轮结束时下发一份权威全文让前端整段替换;没下发时才以拼接结果为准。
  • 工具失败也要返回结果。 把错误写成模型读得懂的结果回填,让它决定重试或换路。但依赖故障(超时、权限、解析失败)不能伪装成「没找到」,否则模型会基于假事实继续推理。

说穿了,循环本身就是一个 while,难的是给这个 while 写好每一个 break。章末的「动手」把其中三个 break 写成了可以跑的代码。

内核二:工具描述是软约束,硬约束要写在闸门里

Harness 那一章讲过两只手:prompt 立规矩,代码设护栏。落到工具上就是这几条:

  • 每个动作只列允许的参数。 一个工具有多个动作时(比如「创建 / 追加 / 中断 / 查询」),在描述里逐个动作写清「只能填哪些字段」。多余字段会被模型乱填。
  • ID 只能抄,不能编。 需要 ID 的参数,描述里写明「必须从之前的工具结果里复制」,服务端再校验存在性。模型编造 ID 是最常见的工具误用之一。
  • 用代码闸门代替提示词禁令。 比如「只有用户能叫停后台任务」这类规则:与其在提示词里写「不要自己停任务」,不如在工具执行时检查这次请求是不是用户发起的,不符合就拒绝并返回原因。
  • 工具定义保持稳定。 工具定义在缓存前缀里,按轮改动会让缓存每轮失效。随轮变化的信息(比如当前有哪些后台任务)放进一段临时消息,而不是改工具 schema。
  • 服务端兜底去重。 模型偶尔会把完全相同的请求连发两遍。服务端对重复请求做幂等去重,告诉模型「已经在处理」,而不是再执行一次。
  • 技能 = 提示 + 脚本 + 依赖。 可复用能力打包成技能按需加载(见 Skills);外部系统优先走 MCP 这类标准协议。

常见的坑

假设你在一条拒绝结果的末尾催一句「现在去做某事」,模型很可能把它理解成「还需要再调一次工具」,于是开始重复调用。工具返回值里的说明文字会显著影响模型的下一步。改这类文字之前,先用固定语料重放几十次、统计行为分布,再决定上不上线(方法见下文「治理」)。

内核三:Agent 不靠内存记事,每轮从账本重建上下文

进程会重启,请求会落到别的实例上。能跨轮延续的只有持久化的记录。所以每一轮都要重新拼一次上下文,顺序按「越稳定越靠前」:

系统提示词(分槽:身份 · 规则 · 输出格式 · 安全)工具定义(每轮不变)历史:压缩摘要(偶尔重写)历史:近期原文(工具调用对完整,派发记录成对保留)缓存边界:之后的内容每轮都变本轮用户消息运行时注入:当前任务名单、提醒(每轮现取,不写回历史)
系统提示词(分槽:身份 · 规则 · 输出格式 · 安全)工具定义(每轮不变)历史:压缩摘要(偶尔重写)历史:近期原文(工具调用对完整,派发记录成对保留)缓存边界:之后的内容每轮都变本轮用户消息运行时注入:当前任务名单、提醒(每轮现取,不写回历史)

运行时注入放在本轮用户消息之后、不写进历史:跨轮的公共前缀不受它影响,同一轮后续几圈还能缓存到它后面。这和 System Prompt 那一章里「稳定段在前」是同一个道理。

上下文变长后有三种手段,代价不同:

手段 做法 代价 适合
清理旧工具结果 处理过的工具输出换成占位,保留调用记录 需要时得重新取 输出大、可重取(搜索、读文件)
摘要压缩 旧对话写成摘要页,保留近期原文 细节可能丢,压太狠会丢关键上下文 长对话的主手段(见上下文压缩)
外部记忆 信息写到窗口外,需要时检索 多一次检索,可能检索不到 跨会话的偏好和长期事实(见自动记忆、mem0)

压缩时必须保护什么?开源实现普遍保护三类:最近 N token 的原文(Cline、opencode)、工具调用与结果的配对完整性(OpenHands 的切点不拆工具对)、用户消息。多 Agent 系统还要额外保护派发记录(子任务 ID 和指令):压掉了,编排者就找不回正在跑的执行者。

还有一点:提示词按配置发布,不跟代码走。放在配置中心,按环境灰度、可回滚。代价是「代码没变,行为变了」,所以每次改动要记版本,并能从 trace 里查到当轮用的是哪一版。

常见的坑

假设压缩策略只保护了派发记录,却没管执行者回来的结果消息,长对话里就会出现「任务明明做完了,Agent 说不出结果」。设计压缩策略时,要逐类列出「哪些消息丢了会导致错误行为」,而不是只按新旧切。

工程化一:多 Agent 只在能拆开时划算

你天天用 Sub Agent,但什么时候该把一个 Agent 拆成多个?Anthropic 和 Cognition 的观点看似相反,其实划出了同一条边界:

适合拆 不适合拆
任务形态 可以并行探索、互相依赖少(多方向调研);或需要隔离环境执行(写代码、生成文件) 多个部分要共享大量上下文、共同做设计决策
风险 编排者要写清任务描述,否则执行者会做偏 并行的执行者各自做出冲突的隐含决定,最后拼不起来
代表观点 Anthropic:研究类任务用编排者 + 并行子 Agent,内部评测上比单 Agent 高 90.2%,尤其是广度优先的查询;它也承认共享上下文、依赖多的任务不适合 Cognition:共享决策的任务,多 Agent 太脆弱,宁可单 Agent

拆了之后,派发和结果回流通常是异步的:

客户端编排者 Agent执行者(沙箱内) 「把三份材料整理成报告」 一句交代(和派发同一条消息)创建子任务:结构化指令,受理即返回任务卡 + 本轮结束进度与结果事件(异步回流)任务卡更新 / 产物另开一轮:用自己的话汇报「只要第二份就行」追加指令:按 ID 发给同一个执行者补充排队,或在步边界注入
客户端编排者 Agent执行者(沙箱内) 「把三份材料整理成报告」 一句交代(和派发同一条消息)创建子任务:结构化指令,受理即返回任务卡 + 本轮结束进度与结果事件(异步回流)任务卡更新 / 产物另开一轮:用自己的话汇报「只要第二份就行」追加指令:按 ID 发给同一个执行者补充排队,或在步边界注入

几条做法:

  • 派发指令把三样东西分开写:用户要求做什么(每句都能追溯到用户原话)、之前已经确认的背景事实和资源 ID、编排者自己的建议(标明仅供参考)。建议和要求混在一起,执行者会把建议当成用户要求。
  • 追加指令按 ID 寻址。开源框架的主流做法都是让编排者的模型按 ID 指定目标:Codex 的 send_message(target)、opencode 续用子会话 ID、OpenHands 的 resume: task_id。系统不替模型猜目标;分不清时先查状态,仍不清楚就问用户。(OpenAI Agents SDK 的 handoff 是另一回事:它按 agent 名把控制权整个移交出去,不是给已在跑的执行者追加指令。)
  • 同一主题复用,不可恢复才新建。补充、修改、追加都发给原执行者,新开的执行者看不到之前做过的工作。用户主动停掉的执行者也可以继续用。
  • 每轮注入任务名单,少查询。把「有哪些执行者、各自什么状态、有没有待回答的问题」作为运行时信息注入,模型就不必每次先查状态。
  • 产品层给用户一个明确指定目标的入口。让用户在任务卡上直接回复,客户端把任务 ID 一起发上来;有明确目标就直接投递,没有再交给模型判断。
  • 结果回来要能「不开口」。心跳、对停止指令的确认、状态变化只更新任务卡,只有需要自然语言汇报的事件才让编排者开新一轮。

工程化二:同一会话同一时刻只能有一个执行体

多实例部署后,两个执行体并发写同一份对话记录、同一个请求被执行两次,都是迟早会发生的事故。三种做法:

做法 原理 代表 取舍
单实例 actor 按会话 ID 固定路由到唯一实例,实例内单线程 Cloudflare Agents(Durable Objects);单进程内加锁(Codex) 最简单;靠平台保证单实例,或只适用单进程
租约 + fencing 执行前抢带过期时间的租约,每次接管递增代际编号;写入时校验编号,拒绝旧执行体 OpenHands agent-server 的会话租约 适合多实例;要处理续约、接管和写入校验
会话收件箱 会触发执行的请求先进会话队列,再按顺序逐个执行 opencode 的会话 inbox(持久化队列 + 进程内单会话协调器);多实例时可以用消息队列 + 租约自己搭 能统一排序和去重;复杂度最高

说白了:actor 是「一个会话只归一个单线程实例处理」;fencing 是给每一任执行者发一个递增的「任期号」,旧任期的写入一律拒收,这样接管之后,旧执行者就算还活着也写不进去。

配套的几条原则:

  • 幂等键从上游身份派生:客户端消息 ID、「定时任务 + 触发时刻」、上游通知 ID,再加类型前缀防撞号。不要在入队时随机生成,上游重投时随机 ID 会让去重失效。
  • 宁可不执行,也不重复执行有副作用的动作:拿不到执行权就不执行;崩溃后不确定某个有副作用的动作是否已经发生,就不重放;收下的请求可以失败,但必须留账。
  • 取消走控制通道,不排队:广播取消信号让执行体立即停下,同时把取消请求持久化下来让执行体定期核对,作为广播丢失时的兜底。
  • 无人在场的轮可以设为不可打断:比如定时任务,新消息排在它后面。是否可打断由服务端判定,不交给请求方决定。

执行中又来一条消息怎么办?业内有五种策略,其中 steer 指「不打断,在下一个步骤边界把新消息塞进当前轮」:

策略 行为 代表实现 适合
reject 拒收新消息 LangGraph 不允许并发输入的严格流程
enqueue 当前轮跑完,新消息作为下一轮 LangGraph 默认;Cloudflare Agents 默认 queue 不能被打断的轮
interrupt 停下当前轮、保留已产出部分,接着处理新消息 LangGraph 对话型编排者:用户改主意要立刻生效
rollback 撤销当前轮(包括原输入),新消息当首条请求 LangGraph 原请求作废
steer 不打断,在下一个步骤边界把新消息注入当前轮 Codex、opencode(默认)、OpenHands;Cline 的 steer 会先中止进行中的模型输出,已在跑的工具照常完成 长任务中途纠偏,且你能控制循环本身

常见的坑

steer 要求你掌控 Agent 循环。假设执行者包装的是一个你改不了内部逻辑的现成 Agent,你就无法确定它会在什么时机吸收新消息,注入的消息可能和它的收尾撞在一起,最后没人处理。这种情况下更稳的做法是只排队,等它空闲再把补充作为新一轮发送;需要立即生效时,用「先打断、再把补充当新指令发」替代 steer。无论哪种,都要让用户看到「有补充在排队」,并能撤回。

工程化三:真凭据不进沙箱,出网默认拒绝

提示词注入无法根除,能限制的是 Agent 被骗之后能做到什么。权限检查管的是「这个动作要不要放行」,沙箱管的是「就算放行了,最坏能坏到哪」。

沙箱出网代理① 校验令牌 ② 白名单,默认拒绝③ 按目的地注入真凭据 ④ 限流与审计凭据库模型服务白名单内的第三方接口其他目的地执行者代码环境变量里只有占位值 / 短期令牌 所有出站请求 取真凭据 拒绝
沙箱出网代理① 校验令牌 ② 白名单,默认拒绝③ 按目的地注入真凭据 ④ 限流与审计凭据库模型服务白名单内的第三方接口其他目的地执行者代码环境变量里只有占位值 / 短期令牌 所有出站请求 取真凭据 拒绝

沙箱里的代码即使被注入攻击完全控制,也拿不到真凭据、发不到白名单以外的地方。两种主流做法:

流派 做法 代表 注意
出口注入凭据 沙箱内只有占位值,代理转发时替换成真凭据或加鉴权头 Codex 的 network-proxy(占位值替换);OpenAI Agents SDK 的 Vercel 沙箱适配器(透传平台的出站 header 注入规则);E2B 也支持按规则给出站请求加 header 最安全;代理成为关键依赖
沙箱内短期凭据 沙箱持有短期、收窄权限的令牌,配合平台层域名白名单 OpenHands(可选:托管 key 被轮换、遇到 401 时重新拉取一次);E2B 的出网 allowOut / denyOut(注意默认全放行) 实现简单;令牌能被沙箱内代码读到,靠权限范围和有效期控制损失

另外两点:

  • 高风险动作要人确认,而且是异步的。 Agent 弹出确认卡后立即结束本轮,用户的回答作为新输入进来,带上卡片 ID 对应到具体请求。不要让一轮在原地同步等用户,否则长时间占着执行权。需要长时间等待时,可以参考 Temporal 的 Signal:等待期间不占计算资源。
  • 列出已知残留风险。 域名白名单不可能堵住所有外泄路径。上线时写清已知的残留风险和对应的监测手段,比宣称「绝对安全」可靠。

工程化四:前端拿到的是三类帧,各有自己的可靠性约定

做过前端的人对这一块最熟。流式响应讲了怎么流,生产里还要把帧分类、约定好每类的投递语义:

帧类别 例子 投递约定 前端怎么处理
对话流 文字片段、轮结束 片段可能丢;轮结束可带权威全文 按轮号拼接;轮结束带了权威全文就整段替换
任务实体 任务卡快照、状态、产物 至少一次,可能重复 整实体替换,只接受更大的版本号
交互卡 提问、审批 带卡片 ID,状态变化单独下发 回答时带回卡片 ID;已处理的卡置灰
轻提示 一次性 toast 不进历史、不回放,用户不在线就丢 只展示,不存状态

几条约定:

  • 轮号是唯一时钟。 每条用户输入由服务端分配递增轮号,下行帧都带上它;出现更大的轮号,旧一轮就不会再续写。
  • 能力协商代替服务端开关。 客户端在会话开始时声明自己支持的协议能力,服务端按声明决定下发什么格式。如果按账号或环境开关切换协议,未升级的客户端可能无法渲染新格式。
  • 续传靠持久化 + 游标。 客户端重连时带上「收到哪了」,服务端只补差量。游标的形式各家不同:Cloudflare Agents 存 SQLite 按整轮重放,OpenHands 按时间戳补发,LangGraph 用 Last-Event-ID,Vercel 的 resumable-stream 按字符偏移。
  • 快照优于逐条重放。 如果前端只会追加渲染,整批重放旧片段会重复显示。更稳的是持久化「这一轮到目前为止的完整文本 + 序号」快照,重连时一次给出,前端整体替换。
  • 多端一致。 同一会话可能同时开在多台设备上:一端的输入要同步给其他端,一端的停止要能停到其他端正在跑的轮。所以输出先发到共享频道,再由各连接所在的实例转发。
  • 冷热分层与单写者。 实时数据先写热存储(快,有过期时间),由唯一的写入方异步投影到冷存储(全,长期保存)。代价是历史接口的数据会稍晚于实时推送,界面以实时推送为准,稍后再从历史补齐。

治理:行为会在不改代码时变化

模型、提示词、工具描述,任何一处变化都会改变 Agent 的行为。没有回放评测,就不知道改动是好是坏。(可观测的基础见 Agent 可观测性。)

  • 每轮一个 trace ID,下发给前端。 模型调用、工具调用、耗时、用量串成一条 trace,在轮结束帧里把 ID 带给前端。用户报问题时拿着 ID 就能定位。
  • 回放评测驱动提示词改动。 取一批真实上下文做固定语料,新旧版本各重放几十次,统计关键行为的分布(误派发、重复调用、空回复),差异显著再上线。比「看几个例子觉得好了」可靠得多。完整的评测体系见下一章。
  • 黄金快照锁住关键输入。 把拼装后的系统提示词和工具定义做成快照测试,无意的改动在 CI 里就会暴露。
  • 开关当回滚闸,并定期毕业。 新行为挂在开关后面灰度,开关同时是事故时的回滚手段。稳定后删掉开关,否则配置组合会越积越多。各环境的开关实际值要可查。
  • 执行环境与服务分开版本化。 沙箱镜像、执行者运行时往往单独发布,要能回答「这个环境此刻跑的是哪个版本的执行者」。
  • 指标看这几类:每轮迭代数分布、撞上限比例、工具错误率、重试次数、排队等待时间、取消与超时次数、上下文长度与压缩次数、缓存命中率。

研发路线:六个阶段

先证明 Agent 能把任务做对,再证明它能在生产里做稳。

  1. 定义任务与评测集:写清楚要做哪些任务、不做哪些,收集 30~100 条真实请求作为评测集,定义「做对」怎么判。完成标准:评测集和判分方式能自动跑。
  2. 单 Agent 循环 + 少量工具:实现循环和三道上限,接 3~5 个最关键的工具,接上 trace。完成标准:评测通过率达到可用水平,每轮能在 trace 里看清。
  3. 上下文与提示词工程:分槽装配、稳定前缀、压缩,提示词移入配置中心。完成标准:长对话不超窗口、不丢关键信息,改提示词有回放评测。
  4. 沙箱与能力扩展:要执行代码或生成文件时引入沙箱和出网代理,能力打包成技能。完成标准:沙箱内零长期凭据,出网默认拒绝,被拒请求可审计。
  5. 按需拆分多 Agent:只有任务可并行或需要隔离执行时才拆。完成标准:派发、追加、停止、结果回流四条路径都有测试,误派发率可度量。
  6. 生产化:单会话串行与幂等、中途输入策略、取消通道、人工确认、交互协议与续传、冷热存储、开关与灰度。完成标准:多实例压测下无并发写和重复执行,断线重连不重复、不丢内容,每个开关都有回滚预案。

第 4 部分的 mini Claude Code 基本就是在单进程里把阶段 2~5 走一遍;阶段 6 的大部分问题,只有多实例部署之后才会真正出现。

上线前检查清单(展开)

循环与工具

  • 迭代上限、软预算、硬超时都设了,撞上限会收口总结
  • 被打断时半截工具调用会补齐或删除
  • 先落盘再发「轮结束」
  • 工具的 ID 参数都由服务端校验存在性
  • 禁止性规则在代码闸门里,而不只在提示词里
  • 依赖故障不会被当成「没找到」

上下文

  • 提示词稳定前缀可缓存,变化信息在末尾
  • 压缩前逐类列出了「丢了会出错」的消息,并都受到保护
  • trace 里能查到当轮提示词版本

编排与执行

  • 同一会话不会有两个执行体同时写
  • 请求有确定性幂等键,重投不会重复执行
  • 中途来新消息的策略按轮类型明确
  • 停止指令不排队,有持久化兜底
  • 追加指令按 ID 投递给原执行者
  • 用户能看到并撤回排队中的补充

安全

  • 沙箱内没有长期凭据
  • 出网默认拒绝,白名单有审计
  • 高风险动作需要人工确认,且确认是异步的
  • 已知残留风险写进了文档

交付与治理

  • 前端以轮号判断最新轮,以权威全文校准
  • 任务实体按版本号收敛,可重复投递
  • 断线重连按游标或快照补齐,不会重复渲染
  • 每轮 trace ID 下发到前端
  • 提示词改动走回放评测
  • 每个新行为都有开关和回滚预案,各环境开关值可查

动手:给循环写好三个 break

全景图里的模块大多要一整套基础设施才能演示,只有「内核一」能用几十行代码讲清楚。这一节在第 1 章的 runAgent 上加三件事:撞迭代上限时收口、软预算到点时不再开新一圈、被打断时补齐半截工具调用。全部跑离线 fake 模型,不需要 API key。

先看效果。场景一,模型一直想再搜一次日志,迭代上限设成 3 圈:

$ cd code/agent-node && node loop_guard/guarded_loop.ts
—— 场景一:撞迭代上限 ——
❯ 构建为什么突然变慢了?
⏺ search_logs({"query":"slow"})
✔ build took 412s (baseline 95s)
⏺ search_logs({"query":"cache miss"})
✔ restore cache: 0/38 hits
⏺ search_logs({"query":"dependency install"})
✔ install deps: 301s
⏺ 已确认:依赖安装阶段缓存全部未命中,占了大部分耗时。还没查清缓存为什么失效,建议下一步对比两次构建的 lockfile。
  [结束原因 max_iterations,3 圈]

[离线 fake 模型] 收口那次请求的 tool_choice:{"type":"none"}
$ cd code/agent && uv run python -m loop_guard.guarded_loop
—— 场景一:撞迭代上限 ——
❯ 构建为什么突然变慢了?
⏺ search_logs({"query": "slow"})
✔ build took 412s (baseline 95s)
⏺ search_logs({"query": "cache miss"})
✔ restore cache: 0/38 hits
⏺ search_logs({"query": "dependency install"})
✔ install deps: 301s
⏺ 已确认:依赖安装阶段缓存全部未命中,占了大部分耗时。还没查清缓存为什么失效,建议下一步对比两次构建的 lockfile。
  [结束原因 max_iterations,3 圈]

[离线 fake 模型] 收口那次请求的 tool_choice:{'type': 'none'}

第 1 章的 runAgent 到这里会抛出「超过 3 轮仍未结束」,前面三次搜索的结论全丢了。现在用户拿到的是一段「查到了什么、还差什么」的总结,调用方还能根据 max_iterations 在界面上标一句「已达上限」。

场景二,模型在同一圈里要了两个工具,第一个跑着的时候用户按了打断,接着补了一句话:

—— 场景二:打断后继续 ——
❯ 跑一下测试然后部署预览
⏺ run_tests({})
✔ 120 passed, 0 failed
⊘ deploy_preview 未执行
  [结束原因 interrupted;缺回音的 tool_use:[]]
  [如果打断时直接退出、不写这条 tool_result 消息:["toolu_run","toolu_deploy"]]
❯ 先别部署,告诉我测试结果就行
⏺ 好的,不部署。测试结果:120 个通过,0 个失败。
—— 场景二:打断后继续 ——
❯ 跑一下测试然后部署预览
⏺ run_tests({})
✔ 120 passed, 0 failed
⊘ deploy_preview 未执行
  [结束原因 interrupted;缺回音的 tool_use:[]]
  [如果打断时直接退出、不写这条 tool_result 消息:['toolu_run', 'toolu_deploy']]
❯ 先别部署,告诉我测试结果就行
⏺ 好的,不部署。测试结果:120 个通过,0 个失败。

倒数第三行是对照组:如果打断时直接退出循环,两个 tool_use 都没有对应的 tool_result,下一次请求就是不合法的历史,接口会拒绝。danglingToolUses 就是照着这条规则写的检查函数。

核心代码只多了两段。第一段是收口:

const wrapUp = async (stop: StopReason, why: string, turns: number): Promise<RunResult> => {
  addUserText(messages, WRAP_UP(why));
  // 工具定义保留(历史里有 tool_use),用 tool_choice=none 禁止再调
  const resp = await ask({ tool_choice: { type: "none" } });
  messages.push({ role: "assistant", content: resp.content });
  return { answer: textOf(resp), stop, turns };
};

for (let turn = 0; ; turn++) {
  if (turn >= maxTurns) return wrapUp("max_iterations", `已达到迭代上限 ${maxTurns} 圈`, turn);
  if (now() - start >= softBudgetMs) return wrapUp("soft_budget", "已用完本轮的时间预算", turn);
  // ……下面和第 1 章一样:调模型、执行工具、回填结果
def wrap_up(stop: str, why: str, turns: int) -> RunResult:
    add_user_text(messages, wrap_up_prompt(why))
    # 工具定义保留(历史里有 tool_use),用 tool_choice=none 禁止再调
    resp = ask(tool_choice={"type": "none"})
    messages.append({"role": "assistant", "content": resp.content})
    return RunResult(_text_of(resp), stop, turns)

turn = 0
while True:
    if turn >= max_turns:
        return wrap_up("max_iterations", f"已达到迭代上限 {max_turns} 圈", turn)
    if now() - start >= soft_budget_s:
        return wrap_up("soft_budget", "已用完本轮的时间预算", turn)
    # ……下面和第 1 章一样:调模型、执行工具、回填结果

你可能会问:为什么不干脆把 tools 去掉?一是历史里已经有 tool_use 和 tool_result 块,保留工具定义,模型看到的上下文和前几圈一致;二是缓存:工具定义排在请求前缀的最前面,删掉它整段缓存都要重建,只改 tool_choice 的话,工具定义和系统提示那段缓存还能命中(消息部分的缓存会失效)。收口提示和最后一组 tool_result 放在同一条 user 消息里(addUserText 负责合并),不会出现两条连续的 user 消息。

软预算的检查也在循环开头,所以它只拦「再开一圈」:正在执行的工具不受影响,结果照常回填。测试里用注入的 now 模拟时钟,验证了这一点。

第二段是打断:

if (signal?.aborted) {
  // 打断后不再执行,但仍要给这个 tool_use 一个回音,否则下一次请求会被接口拒绝
  log(`⊘ ${block.name} 未执行`);
  results.push({ type: "tool_result", tool_use_id: block.id, content: ABORTED, is_error: true });
  continue;
}
if signal is not None and signal.is_set():
    # 打断后不再执行,但仍要给这个 tool_use 一个回音,否则下一次请求会被接口拒绝
    log(f"⊘ {block.name} 未执行")
    results.append({"type": "tool_result", "tool_use_id": block.id,
                    "content": ABORTED, "is_error": True})
    continue

这就是 Codex 的做法:给没执行的调用补一条合成结果,并标成错误。模型下一圈读到「用户已打断,没有执行」,就知道部署没发生,不会以为部署成功了。

这个实验有两处简化,换成真实实现时要补上:

  • 只在两次工具调用之间检查打断。 一个跑了十分钟的工具不会被中途停下。真实实现要把 signal 传进工具,让长任务自己响应取消;硬超时也接到同一个 signal 上(Node 里可以用 AbortSignal.any 把用户打断和 AbortSignal.timeout 合成一个),这样两条取消路径走同一套补齐逻辑。
  • 没有流式输出。 打断如果发生在模型正在输出的时候,还要处理「只生成了一半的 assistant 消息」,常见做法是丢掉这半条,或者把已输出的文字留下、去掉不完整的工具调用。

完整代码和测试:

code/agent-node/loop_guard/guarded_loop.ts         runGuarded / addUserText / danglingToolUses + 两个场景
code/agent-node/loop_guard/guarded_loop.test.ts    5 个用例:end_turn、撞上限收口、软预算、打断补齐、缺回音检查
$ cd code/agent-node && node --test loop_guard/guarded_loop.test.ts
code/agent/loop_guard/guarded_loop.py              run_guarded / add_user_text / dangling_tool_uses + 两个场景
code/agent/loop_guard/test_guarded_loop.py         同样 5 个用例
$ cd code/agent && uv run pytest -q loop_guard

对照你的环境

  • 你在 Claude Code 里派出 subagent 后,主会话拿到它的 ID,之后用 SendMessage 按 ID 或名字继续同一个 agent,它跑完会主动通知主会话。这就是「追加指令按 ID 寻址」和「结果作为新一轮推回」,只是寻址这一步由你或主 agent 显式完成。
  • 你写的 PreToolUse hook,就是「用代码闸门代替提示词禁令」:规则写在记忆里只能提高遵守概率,hook 拦下来才是硬约束(Harness 那一章的主题)。
  • 如果你在前端接过 Agent 的流式接口,「工程化四」那张帧分类表可以直接当对接清单:每类帧问一句「丢了怎么办、重复了怎么办、断线后从哪补」。

回到开头的问题

自己造一个 Agent 从哪下手?先做循环和工具,接上 trace 和评测集,确认它能把任务做对;然后按「多实例、长对话、执行代码、断线」这几类风险,逐层补执行模型、压缩、沙箱、交付协议。

前面拆过的每一章,在这张全景图里都有位置:System Prompt 和压缩在上下文层,文件读写和权限在能力层,Plan Mode 和 Harness 在治理线上。这一章补上的,主要是拆单机工具时看不到的那几层:多 Agent 的派发与追加、多实例下的执行模型、沙箱出网和前端交付协议。

回到主线:模型只会根据上下文预测下一个 token。这一章的每个模块,都是在这个前提下让它看到对的上下文、只能做安全的事、把结果可靠地送到用户面前。

常见坑

  • 只写了循环,没写好停止:没有迭代上限或收口逻辑,撞上限就报错;被打断后留下半截工具调用,下一轮请求直接被模型接口拒绝。
  • 规则只写在提示词里:「不要自己停任务」「必须先读再写」这类规则迟早会被违反,必须有代码闸门。
  • 按轮修改工具定义:每轮把动态信息拼进工具描述,缓存命中率归零,成本和延迟翻倍。
  • 多实例上线后才想起串行:同一会话的两个请求落到两台机器上并发执行,对话记录交错、同一个请求被执行两次。
  • 断线重连整批重放:前端只会追加渲染,重放导致重复内容;应改为游标补差量或快照替换。

练习

  1. 把「动手」里的收口和打断补齐搬进 mini Claude Code 的循环:到达上限时让模型总结并在终端显示结束原因;按 Ctrl+C 时不退出程序,而是补齐半截工具调用、回到输入提示符。
  2. 给你的 Agent 设计一套「执行中又来一条消息」的策略:哪些轮用 interrupt、哪些轮用 enqueue,并说明为什么不用 steer(或为什么能用)。
  3. 开放题:对照本章的上线前检查清单,评估你日常用的一个 Agent 产品(或你在做的项目),找出三条最可能出事故的缺口,并写出你会怎么补。

延伸阅读

开源结论基于 2026 年 10 月各仓库默认分支的源码快照,后续版本可能变化。文中的上限数值只是示例量级,请按自己的任务调整。