原理
理解任何 agent 的地基。第 1–5 章是核心,至少要能不看资料讲清楚;第 6–9 章各回答一个更具体的问题。
Agent、Workflow 与 Harness
基础LLM 只是一个无状态函数
LLM 输入一串消息,输出下一条消息。它不能读文件,不能调接口,也记不住上一次请求。所有“能力”都来自包在它外面的程序。
Harness 就是这层程序:维护对话历史、提供并执行工具、拼装上下文、保存会话、处理权限、发出事件。Claude Code、Codex CLI、pi 都是 harness。
模型像一个无状态的远程服务(同样的输入也可能得到不同的输出) Message complete(List<Message> history);harness 像 Spring + Tomcat,负责生命周期、路由、状态、拦截器和持久化。
Agent稳定含义从“全自动 AI 员工”变成了“在循环里调用工具的 LLM”
- AutoGPT、BabyAGI 一度走红:让 GPT-4 自己拆目标、自己循环。演示惊艳,实际任务几乎跑不通,热度几个月就退了。
- LangChain agents、AutoGen、CrewAI 等框架把“agent”做成了一套抽象,概念多、可控性差。
- Anthropic《Building Effective Agents》给出今天通用的定义,并和 workflow 划清界限。
- Claude Code、Codex CLI、Cursor agent 等 coding agent 正式投入使用,“agent”第一次大规模可用。
为什么 2023 年那批失败了:当时模型的工具调用、长程规划和自我纠错都不够,harness 再花哨也补不上。这和第 1 章结尾的“变薄”是同一个规律:模型能力不到位时,外层程序弥补不了;模型到位后,脚手架反而要拆。
Harness / Scaffold正流行2024 年多叫 scaffold,2025 年起叫 harness,2026 年出现了 harness engineering
- SWE-agent 等研究工作把模型外面的程序叫 scaffold(脚手架),强调它是临时的辅助结构。
- Claude Agent SDK、Codex 等开始把这层叫 harness(挽具),强调它“驾驭”模型、长期存在。
- OpenAI 发表《Harness engineering》:一个团队五个月不手写代码,全部由 Codex 生成;工程师的工作变成设计环境、约束和反馈回路。
和相邻的词:prompt engineering(写好一段提示)⊂ context engineering(每一轮放什么进上下文,第 5 章)⊂ harness engineering(上下文之外,再加工具、沙箱、验证、反馈回路的整套环境)。三个词是逐层扩大的关系,不是新词替代旧词。
基础Agent = 模型 + Harness
agent 和 harness 不是两个并列的零件。harness 是包在模型外面、负责运行和控制的那层程序;agent 是把模型放进 harness、跑起来之后的整个系统。这层程序可以自己写,可以用 SDK,也可以交给厂商托管(第 3 章)。“Agent = 模型 + Harness”是工程上的简化说法:给模型套一层程序不一定就是 agent,还要看下一步由谁决定(下一节)。
沿用上面的 Spring + Tomcat 类比:harness 像 Tomcat 这样的容器,它不是被动的盒子,而是要管生命周期、拦截和状态;agent 像部署进去、正在运行的应用。不同的是,这个应用的规矩可以事先定好(业务规则、权限、怎样才算验收通过),但具体一步步怎么走没有写死,由模型每一轮当场决定。
两边各管一半:
| 模型 | Harness | |
|---|---|---|
| 负责 | 决定做什么:理解目标,选下一步调哪个工具、填什么参数,判断什么时候该收尾、什么时候该问人 | 负责做到、负责把关:执行工具,把结果送回去,维护历史,拼装上下文,拦截危险操作,执行审批和预算上限,保存会话 |
| 做不到 | 直接碰外部世界;记住上一次请求(历史要由外面保存,再送回给它) | 替模型选任务路径。它可以定规矩:改完必须跑测试、失败重试几次、哪些操作要审批、预算用完就停;但如果连每一步怎么走都由代码写死,就成了 workflow(下一节)。workflow 不比 agent 低级,只是适合的任务不同 |
| 只换这一边 | 同一个 harness 换上更强的模型,规划、纠错、何时停下通常会变好,但不保证每一项都变好,要在自己的任务上评测(第 15 章) | 同一个模型换个 harness,能用的工具、能看到的上下文都变了,成绩可能差一大截 |
两边靠一个循环接起来(第 4 章展开):harness 把历史和工具清单发给模型 → 模型回一条消息,可能带着工具调用 → harness 执行工具,把结果追加到历史里 → 再发给模型,直到模型不再调用工具。循环是 harness 写的,每一圈往哪走由模型决定。
注意,“模型不再调用工具”只说明这一段跑完了,不等于任务做成了:模型可能是做完了,也可能是要问用户,或者已经走不下去了,做没做成要另外验证。harness 也可以中途让循环暂停(等人审批),或者因为轮数、预算、超时、用户取消把它终止(第 4 章)。
日常说法里这两个词常被混用。“Claude Code 是一个 agent”说的是它带着模型运行时的样子;“Claude Code 是一个 harness”说的是去掉模型后剩下的那份程序。讨论设计时最好分开问:失败是因为模型没想对,还是 harness 没让它看到该看的、没让它做到该做的?分开问是为了找对下手的地方,但不是说只有后一种才改得了:模型没想对,写 harness 的人往往也能缓解,比如把提示写清楚、补上缺的上下文、给它能算准的工具、加上校验,把报错清楚地反馈给它;这些都不够,再考虑换模型。问题出在哪一边,和该从哪一边改,不一定是同一边。
进阶Workflow 还是 Agent
Anthropic 在《Building Effective Agents》里把 LLM 应用分成两类,这个划分被广泛引用:
| Workflow(工作流) | Agent(智能体) | |
|---|---|---|
| 谁决定流程 | 代码预先写死步骤,LLM 是其中的节点 | LLM 在循环里自己决定下一步、调哪个工具、何时结束 |
| 典型模式 | 提示链、路由、并行、编排者-执行者、评估者-优化者 | 工具调用循环 |
| 优点 | 可预测、好测试、成本可控、延迟低 | 能处理开放、步骤数未知的任务 |
| 缺点 | 只能处理设计时想到的路径 | 成本和延迟高,错误会累积,需要护栏 |
| 适合 | 工单分类、文档抽取、固定审批流 | 写代码、排查问题、调研 |
工程判断的原则:能用 workflow 解决的就不要上 agent。agent 用在步骤不确定、需要探索和试错的任务上。实际系统常常是混合的:外层是固定流程,某个节点内部是一个 agent。
和上一节合起来看:workflow 和 agent 外面都有一层程序,区别在这层程序管到哪一步。workflow 的外层代码自己定好流程,只把模型当成一个函数来调用;agent 的外层只提供工具、循环和护栏,把流程交给模型。通常只有后一种被叫作 harness。
深入为什么 harness 决定上限
模型只能看到 harness 给它的东西、只能做 harness 允许的事。工具描述写得含糊、上下文塞满噪音、没有压缩策略、错误信息看不懂,再强的模型也会表现很差。同一个模型在不同 coding agent 里的基准分数可以差出一大截,差异全部来自 harness。
harness 要回答的,就是首页本质地图里那七个不变的问题。每个问题都来自模型的一条硬约束,后面每一章回答其中一两个:
- 动手:模型只产出 token,输出怎么变成动作?(第 3、4、7 章)
- 上下文:窗口有限还越长越差,每一轮放什么?(第 5、6、8 章)
- 状态:模型无状态,上下文之外存什么、怎么接回来?(第 8、10 章)
- 连接:外部系统和能力怎么接进来?(第 11、16 章)
- 分工:工作怎么拆,流程由谁决定?(本章、第 9 章)
- 验证:输出会自信地出错,怎么知道做对了?(第 2、15 章)
- 防护:出错或被攻击时怎么控制损失?(第 12–14 章)
这七个问题从 2023 年到现在没有变过,变的是每个问题的解法放在哪里:提示词、harness 代码、开放标准、厂商 API,还是模型权重。
pi 的做法极简:system prompt 几百 token、默认 4 个工具,计划模式和 subagent 都放在扩展里。README · “a minimal, extensible agent harness”
pi 对这些问题的回答都偏“极简”:system prompt 只有几百 token,默认只有 4 个工具,不逐个审批工具调用,subagent 和计划模式不进核心而是作为扩展示例提供。读它的源码,等于看一个“只保留必要部分”的 harness 长什么样,这比读一个功能堆满的产品更容易看清本质。
深入前瞻:模型越来越强,harness 会变薄吗
说对了一半。“变薄”的是 harness 里替模型思考的那部分;替模型做事、给模型把关的那部分不但没变薄,还在变厚。还有一部分既没消失也不在 harness 里了,而是转给了模型训练或厂商 API。
变薄
- 长篇 system prompt、大量“务必”“不要”的提醒
- ReAct 式 Thought/Action 文本模板
- 写死的规划步骤、强制更新待办清单
- 解析失败重试、格式修复
- 为弱模型定制的复杂编辑格式
- 固定的 RAG 检索流水线
移交
- 推理与工具调用交织训练进模型
- 跨多个上下文窗口持续工作训练进模型
- 压缩、清理旧工具结果厂商 API
- 工具按需加载、代码执行厂商 API
- 结构化输出、前缀缓存厂商 API
变厚
- 沙箱、权限、凭据代理
- 长时任务的状态、检查点、交接
- 验证手段:测试、浏览器、类型检查
- 并行 agent 的编排与隔离
- 评测、可观测、成本控制
- 多租户、审计、合规
这三列背后有一个机制:能写成示范、或者能用程序判对错的行为,更有机会被训练进模型(第 2 章讲怎么训练进去),所以“替模型想”的那一列倾向于变薄。这是倾向,不是定律:哪段脚手架真能删,要在你的模型和任务上评测才知道。要真的碰外部世界、保存状态、承担责任的事,没法写进权重,所以“替模型做事”的那一列不会因为模型变强而消失。
几个佐证:
- 脚手架在被删。Claude Code 的负责人公开说过,每出一个新模型,团队都会审一遍 system prompt,删掉新模型已经不需要的提醒;他们把 Claude Code 描述为“尽可能薄的一层封装”,并预期很多脚手架会被练进模型。有团队报告过某个模型在上下文快满时会“赶工收尾”,于是加了“清空上下文 + 结构化交接”来绕开,到下一代模型这个毛病消失了,这段代码也就成了死代码。
- 但 harness 的分量没变小。Terminal-Bench 这类按“模型 + harness”组合打分的基准上,同一个模型换不同 harness,成绩可以差十几个百分点。
- 工程重心在转移。OpenAI 的 harness engineering 实验里,人几乎不写代码,工作量全花在让 agent 能自己验证、自己发现问题的环境上:文档结构、lint 规则、可观测性、测试。
对设计的启示:
- 为下一代模型设计。针对当前模型缺陷的补丁,写的时候就预期它会被删。把它们放在扩展或配置里,而不是核心里。pi 把计划模式、子 agent 都放到扩展里,就是这个思路。
- 用评测决定删什么。每换一个模型,关掉某个脚手架跑一遍评测(消融),没有收益就删(第 15 章)。
- 把力气花在“变厚”的那一列。沙箱、状态、验证、评测,这些不会被模型取代,是长期的工程价值所在。
- 警惕被厂商 API 锁定。交给厂商 API 的能力用起来省事,但会让 harness 和某一家绑定(第 5 章服务端压缩)。
- 已有证据agent 的基本形态(LLM + 工具 + 循环)到目前为止没有变,近两年的新东西都是在这个形态上加东西,没有替换它。
- 已有证据已有团队公开说明,每换一代模型就删掉一批提示和脚手架,同时在沙箱、验证、评测上投入更多(见上面的佐证)。
- 作者判断这个趋势会延续:“替模型思考”的部分继续变薄,“替模型做事和把关”的部分继续变厚。具体到某一段代码,仍以消融评测为准。
- 作者判断模型和 harness 一起训练会越来越普遍(厂商用自家 harness 做强化学习),同一个模型在“原厂 harness”里往往表现更好,第三方 harness 需要按模型适配工具格式(第 7 章)。
- 有争议workflow 会不会被 agent 全面取代。模型越强,agent 的适用面越大;但成本、可预测性、合规审计这些理由不会因为模型变强而消失。
什么是 Agent?和直接调用 LLM、和工作流有什么区别?
- 一句话:LLM 在循环中自主选择并调用工具,根据结果决定下一步,直到完成目标。
- 和单次调用比:多了循环、工具和状态。单次调用只能“说”,agent 能“做”。
- 和工作流比:区别在控制流由谁决定,代码还是模型。
- 和 harness 比:harness 是包在模型外面的程序,agent 是“模型 + harness”跑起来的整个系统。模型决定做什么,harness 负责做到。
- 能用工作流解决的不要上 agent;实际系统常是两者混合。
为什么说 harness 比模型更决定 agent 的效果?
- 模型的输入完全由 harness 拼装,模型的动作完全由 harness 执行。
- 工具设计、上下文管理、错误反馈、验证手段,这些都在 harness 里,直接决定成功率。
- 可以举 pi 的例子:极短的 system prompt + 4 个通用工具,依然能完成复杂编码任务,说明能力主要来自“好的工具 + 好的上下文”,而不是长提示词。
模型越来越强,harness 会不会被模型取代?
- 分开看:替模型思考的部分(长提示、模板、固定规划)在变薄;替模型做事和把关的部分(沙箱、状态、验证、评测、成本)在变厚;还有一部分转给了模型训练和厂商 API。
- 证据:Claude Code 团队每代模型都删提醒;同一模型换 harness 在 Terminal-Bench 上差十几个点;OpenAI 的 harness engineering 把重心放在验证环境上。
- 做法:补丁放扩展里,预期会删;用消融评测决定删什么;投入放在不会被取代的基础设施上。
模型从哪来:能力是怎么训练出来的
手册里反复出现“被训练进模型”“模型和 harness 一起训练”。这一章讲清它们的机制。不讲 Transformer 内部,只讲和 agent 有关的部分:模型的硬约束从哪来,会用工具的本事从哪来。第一次读只看两节“基础”即可,其余小节可以等读完第 3、4 章的闭环再回来。
基础预训练只学一件事:猜下一个 token
预训练拿海量文本,遮住下一个 token 让模型猜,按它给正确答案的概率打分(交叉熵损失)、成批地调参数,在万亿级 token 上反复做。文本自带答案,不需要人标注。
agent 面对的几条硬约束,常被笼统地说成“因为模型只会猜下一个 token”。其实它们来源不同:有的来自训练目标,有的来自模型架构,有的来自推理接口和部署预算。下表说的是本书讨论的主流模型,即自回归(decoder-only)Transformer、通过无状态 API 调用的那一类:
| 约束 | 主要来源 | 后面用到 |
|---|---|---|
| 无状态:记忆只能靠每次重发历史 | 推理接口。推理时权重不更新,常见 API 也不在两次请求之间保留模型内部状态。这不是“猜下一个 token”本身决定的:厂商可以在服务端代存历史(第 3 章的 Responses API),研究里也有 Transformer-XL 这类跨片段保留状态的架构 | 第 3、10 章 |
| 上下文窗口有限,越长越难用好 | 架构与预算。注意力计算和 KV 缓存随长度增长,位置编码和训练时见过的长度有限,部署时还有显存和成本预算 | 第 5 章 |
| 相同前缀算出的中间结果(KV)可以复用 | 架构。因果注意力只回看前面的 token,前面的计算不受后文影响 | 第 6 章 |
| 会自信地出错,需要外部验证 | 训练目标。预训练学的是“像训练数据”,后训练学的是“像示范”或“拿高分”,都不直接等于“正确” | 第 15 章 |
| 无法可靠区分哪段文字该执行,哪段只是材料 | 输入表示。指令和材料拼在同一个 token 序列里,没有硬隔离的通道;后训练能降低被带偏的概率,但不能杜绝 | 第 13 章 |
分清来源,才能判断哪条约束会松动、怎么松动。服务端状态改变的是“谁来保存历史”,模型每次仍要从输入重新读起;更长的窗口放宽了上限,却没有消除越长越不准。
预训练出来的模型只会续写。问它“香港天气怎么样?用摄氏度”,它很可能续写出更多关于天气的问题,而不是输出一个天气查询的调用。让它听指令、会用工具,靠后训练。
基础后训练的两种方法
| 方法 | 怎么学 | 例子 | 适合 |
|---|---|---|---|
| SFT(监督微调) | 照着示范学:给“请求 → 正确回复”的样例,仍用猜下一个 token 的目标训练 | “查香港天气,用摄氏度” → {"name":"get_weather","arguments":{"city":"Hong Kong","unit":"celsius"}} | 写得出标准答案的行为:输出格式、工具调用、回答风格 |
| 强化学习(RL) | 自己试、按分数学:同一题生成几个答案,打分,提高高分答案的概率 | 写 is_even(n),跑测试,通过率就是分数 | 写不出唯一示范、但判得出好坏的任务:代码、数学、多步工具任务 |
SFT 像照着评审意见逐行改代码;RL 像只给你 CI 的红绿结果,自己摸索怎么让它变绿。后者不需要有人写出标准答案,只需要有一个靠得住的检查。
进阶缩写很多,其实只有两个维度
后训练的论文里缩写很多:RLHF、RLAIF、RLVR、PPO、GRPO……它们分属两个互不相干的维度:
| 维度 | 选项 | 回答的问题 |
|---|---|---|
| 反馈从哪来 | 人来比较两个回答(RLHF)· 模型按评分标准打分(RLAIF)· 程序判对错:测试、答案核对、任务的最终状态(RLVR) | 谁说这个答案好 |
| 怎么更新参数 | PPO:和“预期得分”比,并限制每一步改动的幅度 · GRPO:同一题的几个答案互相比,高于组内平均的加强 | 拿到分数之后怎么改模型 |
ChatGPT 最初用的是“人来比较 + PPO”,DeepSeek-R1-Zero 用的是“程序判对错 + GRPO”。两个维度可以任意组合。这是“看本质不看名字”的一个小例子:先找出维度,再把名字放进格子,五个缩写就只剩两个问题。
RLHF → RLVR正流行反馈从“人来比较”扩展到“程序判对错”
- OpenAI 的 InstructGPT:人对回答排序,训练一个奖励模型,再用 PPO 优化。这就是 RLHF,ChatGPT 的基础。
- Anthropic 的 Constitutional AI:让模型按一份原则给回答打分,替代一部分人工反馈,即 RLAIF。
- OpenAI o1:用强化学习训练推理。训练时多花算力、回答时多想一会儿,准确率都会提升。
- AI2 的 Tülu 3 报告把“用可验证的答案当奖励”称为 RLVR。
- DeepSeek-R1:R1-Zero 不做 SFT,只用规则判定的奖励(答案是否正确 + 输出格式是否合规)加 GRPO,就练出了长推理。正式发布的 R1 是多阶段的:少量示范 SFT、推理 RL、再一轮 SFT、再全面 RL(这一阶段除了规则奖励,也用奖励模型评判通用回答)。
- 奖励扩展到多步 agent 任务:一次尝试就是一整条“读文件、改代码、跑测试”的轨迹,测试结果就是分数。
和其他词的关系:RLHF / RLAIF / RLVR 说的是反馈从哪来,PPO / GRPO 说的是怎么更新,别混在一起。同一种“程序判对错”的验证器,也用在离线评测(第 15 章)和 agent 运行中的自我检查(第 9 章)。
进阶一次尝试,可以是一整条 agent 轨迹
在数学题上,一次尝试(rollout)是一段解答。在 coding agent 上,一次尝试是一整条轨迹:读文件、改代码、跑测试……最后用隐藏测试或任务结束时的系统状态打分。
要跑这种训练,需要一个能真正执行工具的环境,也就是一个 harness。厂商通常用自家的 harness 来跑。这一点能解释后面好几章会遇到的现象:
- 编辑格式和模型绑定。GPT 系列熟悉
apply_patch,Claude 熟悉str_replace,因为训练时用的就是它们(第 7 章)。 - 同一个模型在训练时用惯的那类 harness 里往往表现更好,换一个接口不熟悉的 harness 可能差一截(第 1 章)。是否真的更好,要在同一模型、同一任务下评测。
- 推理块要原样回传。模型是在“思考、调工具、带着思考继续”的轨迹上练出来的,中途丢掉思考,它就不在熟悉的状态里了(第 3 章)。
- 基准检查最终状态。Terminal-Bench、SWE-bench 检查任务做完后机器或仓库的状态,和训练用的奖励是同一类东西(第 15 章)。
深入验证器看不到的地方,模型会钻空子
题目:时薪 12 美元,干了 50 分钟,挣多少?假如奖励只核对最后的数字:
解答 A:12 ÷ 60 × 50 = 10 #### 10 → 奖励 1,过程正确 解答 B:12 + 50 = 10 #### 10 → 奖励 1,过程是错的,答案碰对了
奖励只看结果,错误的过程也会被加强。训练得足够久,模型会学会利用验证器的盲区(reward hacking),比如让测试通过却没修好问题,甚至去改测试本身。
这个教训贯穿三个地方:训练时的奖励、评测时的判分(第 15 章)、agent 运行时的自我检查(第 9 章)。它们本质上是同一个验证器用在不同时间点。验证器写得越全,三处都越可靠。
深入什么会被训练进模型
把上面合起来,就得到首页的那条经验规律:能写成示范的(SFT)、能用程序判对错的(RLVR),更有机会被训练进模型。这是倾向,不是定律:能判对错不代表模型容易试出对的答案,奖励可能有漏洞(见上一节),学会示范也不代表能可靠泛化,练不练还要看数据和成本。已经发生的例子:
- ReAct 的 Thought/Action 文本模板 → 主流推理模型把“想、做、看”练进了权重,显式模板不再是必需(第 4 章)。
- 强制更新待办清单、防止“谎称完成”的提醒 → Claude Code 团队报告,在他们的新模型上某个这类机制已经不需要(第 9 章)。这是一家在自家模型上的观察;换了模型或任务,是否还需要,要用评测确认。
- 多 agent 的协调:Kimi K2.5 用强化学习只训练协调者、子 agent 保持不动,让模型自己决定什么时候拆分、派给谁。分工这件事也开始被训练进去。
练不进模型的是沙箱、权限、状态持久化、审计、成本控制。它们不是模型的“行为”,而是环境;验证环境本身还是奖励的来源。
像 JVM 的 JIT:反复执行、可度量的热点代码会被编译成机器码,你手写的那层微优化就多余了;但 GC、类加载、安全检查这些运行时职责,不会因为 JIT 变强而消失。
- 已有证据公开的后训练报告(Tülu 3、DeepSeek-R1 等)把可验证奖励作为代码、数学这类任务的主要训练信号之一。验证器的质量直接影响模型在这些任务上能练到多好。
- 作者判断训练环境越来越像真实的 harness:真实仓库、真实终端、长任务。更多 harness 里的行为会被练进模型,第三方 harness 要跟着模型调整工具格式。
- 作者判断“能不能写出验证器”会成为判断一个领域 agent 进步快慢的先行指标:有测试的领域(代码)进步最快,难以判对错的领域(写作、设计)慢得多。
- 有争议多 agent 的协调能被训练到什么程度。公开的对比还很少,各家比较时的预算条件也不同。
为什么同一个模型换一个 harness,表现会差很多?
- 后训练用 harness 跑 rollout,工具格式、编辑格式、推理块的回传方式,都是模型在训练时见惯了的。
- 换一个 harness,等于换了一套它不熟悉的接口。
- 再加上第 1 章讲的:上下文、工具描述、错误信息都由 harness 决定。
RLHF、RLVR、PPO、GRPO 是什么关系?
- 两个独立的维度。反馈从哪来:人(RLHF)、模型(RLAIF)、程序(RLVR)。怎么更新:PPO、GRPO。
- 可以任意组合,比如 DeepSeek-R1-Zero 是“程序判对错 + GRPO”。
哪些 harness 功能最可能被练进模型?怎么判断?
- 看它能不能写成示范或被程序判对错:输出格式、推理和工具交织、规划习惯、防偷懒的提醒,都更可能被练进模型。但“更可能”不是“已经”:要不要删,看你的模型在你的任务上的评测。
- 沙箱、权限、状态、审计、成本,是环境而不是行为,练不进去。
- 落到实践:换模型时关掉一个脚手架跑评测(消融),成绩不降就删。
LLM API 与 Function Calling
基础一次带工具调用的往返
// → 请求:system + 工具定义 + 历史 { "systemPrompt": "You are an expert coding assistant…", "tools": [{ "name": "read", "description": "Read file contents", "parameters": { "type": "object", "properties": { "path": { "type": "string" } }, "required": ["path"] } }], "messages": [{ "role": "user", "content": "pom.xml 里用了哪些依赖?" }] } // ← 响应:模型没有回答,而是请求调用工具 { "role": "assistant", "stopReason": "toolUse", "content": [{ "type": "toolCall", "id": "call_1", "name": "read", "arguments": { "path": "pom.xml" } }] } // → 下一次请求:全部历史 + 工具结果 { "role": "toolResult", "toolCallId": "call_1", "toolName": "read", "content": [{ "type": "text", "text": "<project>…</project>" }], "isError": false }
- 模型不执行任何东西。它按训练学到的格式输出工具名和 JSON 参数,执行的是 harness。
- 无状态。每次请求都把完整历史重发,“记忆”是 harness 拼出来的。
- 模型靠名字和描述选工具。描述写得不清,就会选错或填错参数。
Function calling / Tool use已成标配不是没人讨论了,是变成了多数工具协议的底层形态
- OpenAI 在 GPT-4 / 3.5 上推出 function calling,模型第一次被专门训练输出“工具名 + JSON 参数”。
- Anthropic tool use 正式可用;各家格式大同小异,都是 JSON Schema 描述工具。
- OpenAI 推出 Structured Outputs(严格按 schema 约束解码),其他厂商陆续跟进。
- 推理模型把思考和工具调用交织在一起(先想、调工具、看结果、再想),工具调用从“一问一答”变成连续动作。
和新词的关系:MCP、tool search、code mode 大多建立在它之上:MCP 决定工具从哪来,tool search 决定什么时候把定义给模型,code mode 让模型用代码批量调用,落到模型面前的通常仍是一次工具调用(第 16 章)。但 JSON 只是最常见的表达,不是唯一的:OpenAI 的 custom tools 允许工具接收自由文本,并可以用语法(Lark、正则)约束格式,适合补丁、SQL 这类本来就不是 JSON 的输入(官方文档)。更稳的抽象是一个闭环:模型提出动作请求,运行时校验并执行,再把结果回传;用 JSON、自由文本还是代码来表达请求,是可以变的那一层。
进阶stop reason、流式与错误
| stopReason | 含义 | harness 该做什么 |
|---|---|---|
stop | 模型说完了 | 结束本次运行 |
toolUse | 要调用工具 | 执行工具,回填结果,继续循环 |
length | 达到最大输出 token | 工具参数可能被截断,不能执行 |
error | 请求出错 | 按错误类型重试或结束(第 14 章) |
aborted | 被取消 | 保留已收到的部分,结束 |
pending | 仅出现在流式的中间状态 | 等待 |
- 流式事件:响应以 SSE 逐块到达,统一成
text_delta、thinking_delta、toolcall_delta、toolcall_end等事件。 - 流式中的工具参数是半截 JSON。界面想提前显示“正在写入 X 文件”,就要做“尽力而为”的增量解析;真正执行必须等
toolcall_end拿到完整参数,并经过 schema 校验。 - 参数不合法(缺字段、类型错、调了不存在的工具)时,不要抛异常中断任务,把错误作为
isError的工具结果返回,模型通常下一轮就能改对。 - 推理块要原样回传。推理模型在调用工具前会先输出思考内容(Anthropic 是带签名的 thinking 块,OpenAI 是可加密的 reasoning item)。在工具调用的往返中,harness 必须把这些块原样放回下一次请求,否则模型会丢掉推理过程,有的厂商会直接拒绝请求。这也是“跨厂商切换”难的根源(见下)。
- 思考预算是一个参数。现在的模型大多可以调思考强度(effort / thinking budget)。harness 可以按任务难度设置:简单的格式化用低档,排查疑难问题用高档。它影响成本、延迟,有时还影响缓存键(第 6 章)。
pi 的做法错误是值不是异常;流式的半截参数只用来展示;被截断的工具调用整批判为失败。packages/ai/README.md · Error Handling / Streaming Tool Calls
- 错误是值,不是异常。流一旦建立就不会 throw:出错时发出
error事件,最终消息的stopReason为"error"或"aborted",并保留已收到的部分内容和 token 用量。上层只需看 stopReason 分支,不需要到处 try/catch。 - 半截参数:
toolcall_delta期间arguments是对不完整 JSON 的尽力解析,至少是{};文档特别提醒字段可能缺失、字符串可能截断在半个词。 - 截断的调用不执行:agent-loop 遇到
stopReason === "length"会调用failToolCallsFromTruncatedMessage,把这条消息里的工具调用全部判为失败。
深入约束解码与跨厂商切换
- 约束解码(constrained sampling):部分厂商支持在解码时强制输出符合 JSON Schema,参数就不会不合法。代价是不是所有模型都支持,schema 也有限制(例如要求
additionalProperties: false)。 - 跨厂商切换:同一个会话中途换模型(比如主模型故障降级),历史消息要能被另一家理解。难点在各家私有的内容块,比如推理过程(thinking)。
pi 的做法能用严格模式就用,不支持就退回普通调用;换厂商时把对方的 thinking 块转成普通文本。packages/ai · Constrained Sampling / Cross-Provider Handoffs
const strictTool: Tool = { name: 'edit_file', description: 'Edit a file', parameters: Type.Object({ path: Type.String(), content: Type.String() }, { additionalProperties: false }), constrainedSampling: { type: 'json_schema', strict: 'prefer' } // 'require' 时不支持的模型直接报错 };
strict: 'prefer':支持就用厂商的严格模式,不支持就退回普通工具调用。这是“能力探测 + 优雅降级”的典型写法。pi 内置的 read / bash / edit / write 都声明了strict: "prefer"。- 跨厂商时:同一厂商的 assistant 消息原样保留;来自其他厂商的消息,其 thinking 块会被转成带
<thinking>标签的普通文本,工具调用和文本保持不变。
无状态 API 还是有状态 API。上面讲的都是无状态调用:每次把完整历史发过去。厂商也提供了有状态的选择,比如 OpenAI 的 Responses API 可以只传 previous_response_id,历史存在服务端;还可以直接用服务端工具(网页搜索、代码执行),工具在厂商那边执行完再把结果交给模型。这等于把一部分 harness 搬到了厂商那里。
- 好处:少传数据、少写代码,服务端工具开箱即用。
- 代价:状态在别人手里,换厂商、审计、离线回放、合规都变难。coding agent 这类需要分支、恢复、跨厂商降级的场景,主流做法仍是客户端保存全部状态。pi 也是这样。
Assistants API已淘汰“托管 agent”的第一次尝试,2026 年 8 月停用,被 Responses API 取代
- OpenAI 推出 Assistants API:thread、run、内置检索和代码解释器,状态全托管。
- 推出 Responses API:保留服务端工具和可选的服务端状态,但调用模型回到简单的“一次请求一次响应”。
- 宣布 Assistants API 弃用,一年后(2026-08)停止服务。
教训:把循环、状态、检索全包进黑盒的抽象太重了,开发者需要能控制每一轮。后来的主流形态是“底层给原语,控制权交还开发者”:Responses API 的服务端工具仍可在一次请求里连续执行,但调用自己的函数时,每一轮由客户端驱动。这和第 1 章的 workflow / agent 之争、和 2023 年 LangChain 的降温是同一类教训。
Function Calling 的原理是什么?
- 模型经过训练,能按特定格式输出工具名和参数;工具 schema 由 API 服务端注入到提示中。
- harness 解析输出、校验参数、执行、把结果作为 toolResult 回填,再发起下一次请求。
- 本质是“模型提出请求、程序执行、结果回填”的协议,模型没有执行能力。
流式输出时,工具参数还没收完,界面想先展示怎么办?
- 对不完整 JSON 做容错的增量解析,只用于展示,字段要逐个判空。
- 执行前必须等完整参数并重新校验,绝不能拿半截参数执行。
- 如果最终 stopReason 是 length,整批调用作废。
- 已有证据“JSON Schema 描述参数 + 模型输出结构化调用”是目前各家 API 最常见的形态;同时也出现了自由文本加语法约束的工具,JSON 不是唯一表达。
- 已有证据主流推理模型把推理和工具调用交织在一起,厂商文档要求 harness 原样保存和回传推理块。
- 作者判断“模型提出动作请求 → 运行时校验、执行、回传”这个闭环会长期稳定;请求的表达形式(JSON、受语法约束的文本、代码)和上层的发现、批量调用方式还会继续变。
- 作者判断厂商会提供更多服务端工具和托管能力。harness 要做的选择是:哪些交给厂商省事,哪些必须握在自己手里(状态、权限、审计)。
Agent Loop:harness 的心脏
基础循环本身
调用模型 → 有工具调用就执行并回填 → 再调用模型 → 直到不再调用工具。这就是 ReAct(Reason + Act,2022)思路的工程实现。“一次模型回复 + 它引出的工具执行”叫一个 turn。
不再调用工具时有两种情况:任务完成,或者需要用户补充信息(缺关键参数、所有方案都走不通、要做不可逆的操作)。停下来问人是循环的正常出口,不是失败。逐步点击模拟器,观察代码、事件和 messages 的变化:
ReAct已练进模型循环结构留下来了,主流模型不再依赖显式文本模板
- Chain-of-Thought 论文:让模型“一步步想”能显著提升推理。
- ReAct 论文:思考(Thought)、行动(Action)、观察(Observation)交替进行。当时靠提示词模板实现,harness 用正则从文本里解析出 Action。
- function calling 出现,Action 不再靠正则解析,变成结构化输出。
- o1、DeepSeek-R1、Claude 的 extended thinking 等推理模型出现;随后推理和工具调用在模型内部交织进行,通过强化学习训练出来。
现在:支持原生推理和工具调用的模型,通常不必再在提示词里写“Thought: … Action: …”,这部分能力练进了模型;显式模板仍可以用,用不用取决于模型、任务和 harness 的设计;但“想 → 调工具 → 看结果 → 再想”这个循环本身就是今天每个 harness 的主循环。这是“练进模型”的典型样子:思想留下,实现方式转进了模型里。
判断完成:开始前写下成功标准,比如“这几个测试通过、构建不报错”,循环结束后由程序检查。模型说“完成了”不算数。完整的做法在第 9、15 章。
进阶生产级的 Java 版本
只有 while 循环的版本离生产很远。下面标注的 9 处才是真正的工程量:
public final class AgentLoop { private static final int MAX_TURNS = 50; // ① 防止死循环 private final LlmClient llm; private final Map<String, Tool> tools; private final ToolGuard guard; private final ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor(); public String run(Session session, String input, CancelToken cancel) { session.append(Message.user(input)); for (int turn = 0; turn < MAX_TURNS; turn++) { cancel.throwIfCancelled(); // ② 支持中止 AssistantMessage reply = llm.complete(session.buildContext(), toolSpecs()); session.append(reply); // 先持久化,崩溃后可恢复 switch (reply.stopReason()) { case ERROR, ABORTED -> throw new AgentException(reply.errorMessage()); case LENGTH -> { // ③ 截断的工具调用不执行 reply.toolCalls().forEach(c -> session.append(Message.toolError(c.id(), "output truncated"))); continue; } default -> {} } if (reply.toolCalls().isEmpty()) return reply.text(); // ④ 先串行做校验和权限检查,再并行执行(与 pi 相同的两阶段) List<Prepared> prepared = reply.toolCalls().stream().map(this::prepare).toList(); List<CompletableFuture<Message>> futures = prepared.stream() .map(p -> p.immediate() != null ? CompletableFuture.completedFuture(p.immediate()) : CompletableFuture.supplyAsync(() -> execute(p, cancel), pool) .completeOnTimeout(Message.toolError(p.id(), "timeout"), 120, TimeUnit.SECONDS)) // ⑤ 超时 .toList(); futures.forEach(f -> session.append(f.join())); // ⑥ 按调用顺序回填 } throw new AgentException("exceeded " + MAX_TURNS + " turns"); } private Prepared prepare(ToolCall call) { Tool tool = tools.get(call.name()); if (tool == null) return Prepared.fail(call, "Tool " + call.name() + " not found"); try { JsonNode args = SchemaValidator.validate(tool.schema(), call.arguments()); // ⑦ 校验参数 Decision d = guard.check(call.name(), args); // ⑧ 权限钩子 return d.blocked() ? Prepared.fail(call, d.reason()) : Prepared.ok(call, tool, args); } catch (Exception e) { return Prepared.fail(call, e.getMessage()); // ⑨ 错误回填,不中断 } } private Message execute(Prepared p, CancelToken cancel) { try { String out = p.tool().execute(p.args(), cancel); // 工具内部负责超时后杀进程树 return Message.toolResult(p.id(), Truncator.truncate(out)); // 行数 / 字节双上限 } catch (Exception e) { return Message.toolError(p.id(), e.getMessage()); } } }
completeOnTimeout 只是让 future 提前返回默认值,底层任务还在跑。如果工具是一个 shell 进程,超时后必须真正杀掉它,而且要杀整个进程树(ProcessHandle.descendants() 逐个 destroyForcibly()),否则 mvn 拉起的子 JVM 会成为孤儿进程。pi 的 bash 工具正是这么做的(第 7 章)。深入pi 的 runLoop:两层循环 + 两阶段执行
pi 的做法两层循环分开处理“运行中插话”和“结束后追加”;循环只认注入的钩子,不认识会话和扩展。packages/agent/src/agent-loop.ts · runLoop()
let pendingMessages = await config.getSteeringMessages?.() || []; while (true) { // 外层:处理 follow-up(任务结束后才发的消息) let hasMoreToolCalls = true; while (hasMoreToolCalls || pendingMessages.length > 0) { // 内层:工具调用 + steering if (lastCompletedTurn) { await config.prepareNextTurn?.(...); /* 自动压缩发生在这里 */ } for (const m of pendingMessages) currentContext.messages.push(m); // 注入插话 await config.prepareRequest?.(...); // 请求前最后一次重建上下文 const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn); if (message.stopReason === "error" || message.stopReason === "aborted") { /* turn_end, agent_end */ return; } const toolCalls = message.content.filter(c => c.type === "toolCall"); hasMoreToolCalls = false; if (toolCalls.length > 0) { const batch = message.stopReason === "length" ? await failToolCallsFromTruncatedMessage(toolCalls, emit) : await executeToolCalls(currentContext, message, config, signal, emit); hasMoreToolCalls = !batch.terminate; for (const r of batch.messages) currentContext.messages.push(r); } const decision = await config.finishTurn?.(...); // 可以要求结束或再跑一轮 await emit({ type: "turn_end", message, toolResults }); if (decision?.action === "end") { /* agent_end */ return; } pendingMessages = await config.getSteeringMessages?.() || []; } const followUps = await config.getFollowUpMessages?.() || []; if (followUps.length > 0) { pendingMessages = followUps; continue; } break; } await emit({ type: "agent_end", messages: newMessages });
几个值得学的设计:
- 两层循环把“运行中插话”(steering,内层每个 turn 结束时检查)和“结束后追加”(follow-up,外层在本该停下时检查)分开。
- 两阶段执行(
executeToolCallsParallel):先按顺序对每个调用做prepareToolCall(查找工具 → 参数预处理 → schema 校验 →beforeToolCall钩子),通过的再用Promise.all并行执行。这样权限弹窗是一个一个出现的,而执行仍然并行。 - 按源顺序回填:
tool_execution_end事件按完成顺序发出(界面更实时),但持久化的 toolResult 消息按 assistant 消息里的原顺序排列。 - terminate 要整批同意:工具结果可以带
terminate: true表示“这批做完就别再请求模型了”,但只有整批所有结果都同意时才生效,避免一个工具擅自中断其他工具的后续。 - 可串行化:任一工具声明
executionMode: "sequential",整批就退化为串行,用于共享可变状态的工具。 - 钩子注入:压缩、上下文变换、权限、结束判定都是
config上的函数,循环本身不认识“会话”“扩展”这些上层概念,可以用假的 LLM 单独测试(第 15 章)。
写一个 agent loop,并说明它的终止条件。
- 正常终止:回复中没有工具调用。可能是做完了,也可能是需要用户补充信息或确认。
- 异常终止:出错或被中止、达到最大轮数、超过 token / 费用预算、整体超时、用户取消、所有工具结果要求 terminate。
- 写完主体后主动补充:参数校验、权限钩子、两阶段执行、超时并杀进程树、结果截断、按序回填、每步持久化。
Agent 陷入死循环(反复调同一个工具)怎么办?
- 硬限制:最大轮数、token / 费用预算、墙钟超时。
- 检测:对 (工具名, 参数) 做哈希,连续 N 次相同就注入提示或终止。
- 根因:通常是错误信息不够清楚,模型不知道错在哪。改进错误信息比加限制更有效。
- 给人留出口:随时中止和 steering。
并行执行工具时,权限确认怎么处理?
- 如果在并行任务里各自弹确认框,用户会同时看到多个弹窗,且顺序混乱。
- 做法是两阶段:先顺序跑完所有前置检查(含人工确认),再并行执行通过的调用。pi 的
executeToolCallsParallel就是这样。 - 被拒绝的调用直接生成错误结果,不进入执行阶段。
- 已有证据到目前为止,loop 本身的结构没有变。变的是它能接下多长的任务:METR 的 2019–2025 数据显示,agent 能以 50% 成功率完成的任务,其长度(按人类专家所需时间算)大约每 7 个月翻一番。这衡量的是任务难度,不是 agent 自己连续跑了多久;不过实际产品里,单次运行也确实已从几分钟走向几小时。
- 作者判断运行变长之后,工程重点从“内层循环”移到“外层循环”:跨上下文窗口、跨会话、跨进程重启怎么接着干(第 9、10 章的长任务部分)。
- 作者判断中途插话(steering)和排队消息会成为标配,因为人不会守着一个跑几小时的任务等它结束。pi 的两层循环正是为此设计的。
上下文工程(Context Engineering)
基础上下文由什么组成,为什么不能无限塞
模型每一轮看到的上下文,可以拆成五个槽位。harness 的工作就是每一轮从这五处挑选内容、拼成一次请求。后面讲的各种技巧,都是在决定某个槽位放什么、放多少:
工具结果通常是增长最快的部分。为什么不能全都塞进去:
- 硬上限:超过上下文窗口,请求直接失败。
- 成本:每个 turn 都重发全部历史,单次输入随轮数线性增长,整个任务的总输入近似平方增长。
- 效果:上下文越长,模型对中间信息的利用越差,指令遵循也变弱(常被称为 context rot、lost in the middle)。
Context engineering稳定2025 年中出现,取代 prompt engineering 成为主流说法
- prompt engineering:琢磨措辞、角色扮演、few-shot 示例。面向的是单次调用。
- Shopify CEO Tobi Lütke 提出用 context engineering 代替 prompt engineering,Karpathy 附和:“在每一步给模型恰好需要的信息”是一门手艺。
- Manus 团队发表《Context Engineering for AI Agents》,总结了 KV 缓存命中率、保留错误、复述目标等实战经验(见下文)。
- Anthropic 发表《Effective context engineering for AI agents》,这个说法被广泛采用。
- 讨论范围再扩大到 harness engineering(第 1 章名词卡)。
为什么换词:agent 一次任务有几十轮,每轮的上下文由 system prompt、工具定义、历史、工具结果、记忆共同组成,措辞只是其中很小一块。换词反映的是问题的重心从“怎么说”变成了“放什么、放多少、什么时候放”。
长上下文 vs “RAG 已死”仍在演变窗口变大没有让上下文管理消失
- Gemini 1.5 支持 100 万 token 上下文,“RAG 已死”的说法流行起来:全塞进去不就行了?
- Chroma 的 Context Rot 研究在 18 个模型上测得:输入越长,表现越不稳定,即使任务很简单。
- 百万级窗口成为高端模型的常见配置,但 coding agent 仍普遍在远未填满时就压缩。
结论:窗口大小是“能放多少”,不是“放了能用好多少”。大窗口让压缩来得更晚、让子 agent 能看得更多,但上下文工程的必要性没变。
进阶五种控制手段
| 手段 | 做法 | 代价 |
|---|---|---|
| 工具输出截断 | 单次结果限制行数或字节,全文写临时文件并告诉模型路径 | 需要时得再读 |
| 渐进式披露 | 只放索引(名字 + 描述),需要时再读全文 | 模型可能不去读 |
| 压缩(摘要) | 接近上限时把旧历史总结成摘要,保留最近消息 | 丢细节、花钱、缓存失效 |
| 清理旧工具结果 | 早期大块输出替换成占位说明 | 需要时得重新获取 |
| 子 agent 隔离 | 大量中间结果留在子 agent 里,只返回结论 | 多一次 agent 的成本 |
此外还有几条被生产环境反复验证过的经验,出自 Manus 团队 2025 年的总结,后来被很多 harness 采纳:
- 保留失败记录。报错、失败的尝试不要从上下文里删掉。模型看到“这条路走不通”,才不会重复犯错。
- 复述目标。长任务里让 agent 维护一份 todo 文件并不断更新,相当于把目标反复写到上下文末尾,对抗“中间信息被遗忘”。
- 文件系统是外部上下文。大块内容写进文件,上下文里只留路径;需要时再读。只要路径还在,压缩就是可恢复的。
- 避免示例导致的惯性。上下文里一连串格式相同的操作会让模型机械模仿。适当加入一些变化。
红线 = 窗口 − reserveTokens(16384,给回复预留),超过就压缩;压缩后保留最近约 keepRecentTokens(20000)。每个 turn 按增加 7k token 估算。
pi 的做法最近一次厂商报告的真实用量,加上之后新增内容按“字符 ÷ 4”估算。coding-agent/src/core/compaction/compaction.ts · estimateContextTokens()
token 怎么算:精确计数要调厂商接口,太慢。pi 的做法是取最近一条 assistant 消息里厂商报告的真实用量作为基数,再对它之后新增的消息按“字符数 ÷ 4”估算。既准确又不用额外请求。
深入pi 的 system prompt 与压缩算法
pi 的 system prompt 由 system-prompt.ts 按命名 section 拼装。下面的拼装器使用源码里的真实文本(docs 段已省略),勾选看变化:
- 规则跟着工具走:每个工具贡献一句说明和若干使用准则;没有 grep / find / ls 时自动加一条“用 bash 查找文件”。
- 按 section 增量更新:会话里记录的是 section;之后某段变化(如新装 skill),只追加这一段的补丁,保持前缀稳定(第 6 章)。
pi 的做法永不在工具结果处切;结构化摘要、增量更新;只追加不删除;溢出时压缩后只重试一次。coding-agent/docs/compaction.md · compaction/compaction.ts
压缩算法的细节,每一条都对应一个实际出过的问题:
- 触发:
contextTokens > contextWindow − reserveTokens;多轮运行中在工具结果追加后、下一次请求前(prepareNextTurn)检查。 - 切分点:从最新往回累计到
keepRecentTokens。永远不在 toolResult 处切,因为工具结果必须和它的调用在一起,否则厂商会拒绝请求。一般在用户消息边界切;单个用户回合本身就超预算时,在回合中间的 assistant 消息处切,并为前半段单独生成一份摘要再合并。 - 序列化防“接话”:送去总结的历史被转成
[User]: …、[Assistant tool calls]: read(path="foo.ts")这样的纯文本,避免模型把它当成对话继续下去;每条工具结果只保留前 2000 字符。 - 结构化摘要:固定章节 Goal / Constraints & Preferences / Progress(Done、In Progress、Blocked)/ Key Decisions / Next Steps / Critical Context,末尾附
<read-files>和<modified-files>。 - 增量摘要:再次压缩时把上一次摘要作为输入,文件列表跨多次压缩累计。
- 只追加:写入一条 compaction 记录(摘要 +
firstKeptEntryId+tokensBefore),原记录不删。 - 溢出恢复:厂商报上下文溢出时,压缩后重试一次;压缩失败就不重试,避免死循环。
压缩正在转到厂商 API。pi 自己实现压缩,但从 2025 年下半年起,厂商陆续提供了托管方案:
| 方案 | 做法 | 注意 |
|---|---|---|
| Anthropic 上下文编辑(2025-09) | 服务端自动清掉旧的工具调用结果 | 改动了前缀,会影响缓存 |
| Anthropic 服务端压缩(2026-02,beta) | 超过阈值时由服务端生成摘要,返回一个 compaction 块,客户端原样带回 | 摘要可读,但生成逻辑不受你控制 |
OpenAI /responses/compact | 传入完整上下文,返回压缩后的新窗口,其中的压缩项是加密的 | 摘要不可读、不可审计,只能在同一厂商使用 |
| 自己实现(pi) | 如本节所述 | 可控、可审计、可跨厂商,但要自己维护 |
取舍和第 3 章的“有状态 API”一样:托管省事,但牺牲可移植性和可审计性。需要跨厂商降级、需要审计的平台,通常仍然自己做,或者两种都支持、按模型选择。
对话超过上下文窗口了,怎么办?
- 先治源头:工具输出截断、大文件分段读、搜索交给子 agent。
- 再压缩:阈值 = 窗口 − 回复预留;保留最近 N token;结构化摘要;不在工具结果处切分。
- 原始记录不删,便于审计和回溯。
- 最后一道保护:溢出错误时压缩后重试一次。
- 点出代价:丢细节、摘要成本、缓存失效,阈值不能太激进。
怎么估算上下文的 token 数?
- 精确:调厂商的计数接口或本地 tokenizer,慢且各家不同。
- 实用:用最近一次响应里厂商报告的上下文总用量(输入、缓存读写,再加这条回复本身的输出)作为基数,加上之后新增内容的粗估(字符 ÷ 4)。pi 就是这么做的。
- 中文字符与 token 的比例和英文不同,粗估要留余量,这也是 reserveTokens 的作用之一。
- 已有证据窗口在变大,但长上下文的质量衰减被多项研究反复测到(Chroma 的 Context Rot 等),所以上下文仍要调度。
- 已有证据Anthropic、OpenAI 都已提供托管的压缩或清理旧结果。harness 多了一个选择:自己实现,或用厂商的并补上审计。
- 作者判断模型会被专门训练去适应压缩后的上下文、甚至自己决定何时压缩(已有厂商把“跨多个上下文窗口连续工作”作为模型卖点,学术界也在做学习型压缩)。到那时,pi 这种手写的切分规则会变薄。
- 有争议“清空上下文 + 结构化交接”和“压缩”哪个更好。不同模型、不同任务结论不同,需要自己评测(第 9 章长任务)。
Prompt Caching 与成本
基础前缀缓存是什么
主流厂商都支持前缀缓存:如果这次请求的开头和之前某次完全一致,这部分的计算(Transformer 的 KV cache)可以复用,价格通常只有正常输入的一小部分,首 token 延迟也明显下降。缓存有生存时间(TTL),过期就失效。
为什么只能缓存前缀:模型的注意力只回看前面的 token(第 2 章),所以开头那段算出的中间结果和后面写了什么无关,可以原样复用;但只要前面改了一个字,它后面的所有结果都要重算。“只追加、不改开头”这条设计约束,就是从这里来的。
agent 的历史不断追加,天然适合前缀缓存:第 N 轮请求的前缀就是第 N−1 轮的全部内容。长会话里,缓存往往是最省钱的一项。
Prompt caching已成标配从可选功能变成计费的默认维度,也变成 harness 设计的硬约束
- Anthropic 推出 prompt caching(需显式标记缓存断点);DeepSeek 推出基于硬盘的上下文缓存。
- OpenAI 推出自动前缀缓存,无需改代码。
- 更长的 TTL 选项(如 1 小时)出现;Manus 公开说 KV 缓存命中率是生产 agent 最重要的单一指标。
- 连 MCP 规范都为它让路:工具列表要求顺序确定,避免重连后工具顺序变化导致缓存失效(第 16 章)。
现状:没人专门讨论它,是因为它已经是默认前提。新设计都要先回答“会不会破坏前缀”:工具增删、system prompt 改动、压缩、服务端上下文编辑都要算这笔账。
进阶怎么设计才能命中
- 稳定内容在前:system prompt、工具定义放最前面,且不要放当前时间、随机 ID。
- 只追加不修改:中途改动前面的任何一条消息、增删工具,之后的缓存全部失效。
- 压缩的隐性成本:压缩替换了历史,之后的请求通常无法再复用旧历史的缓存,从摘要开始的部分一般要重新计算;system prompt、工具定义这些没变的开头仍可命中。实际命中范围还取决于缓存断点和有效期。
- 路由粘性:同一会话的请求尽量发往同一厂商、同一区域、同一缓存键(会话 ID)。
- 屏蔽工具而不是删除工具:某个阶段不想让模型用某些工具时,删掉它们的定义会改动前缀。Manus 的做法是工具定义保持不变,在解码时限制可选的工具(或用提示告诉模型当前不可用)。pi 用追加 system 消息表达工具变化,也是为了不改前缀。
- 工具顺序要确定:工具列表每次序列化的顺序必须一样。从 Map 或远程服务拿到的工具列表要先排序。
pi 的做法工具和提示词的变化用追加的增量消息表达,不改写开头,前缀缓存不失效。coding-agent/docs/session-format.md · extensions.md
- 工具列表变化不重写首条 system 消息,而是追加一条带
toolsAdded/toolsRemoved的 system 消息;prompt 变化追加 section 补丁。前缀因此保持不变。 - 文档明确写道:无法表达这种增量的厂商会收到一份完整检查点,“which can invalidate the cached prefix”。
深入缓存预热:用期望收益做决策
缓存会过期。如果用户思考了 6 分钟才回复,而缓存 TTL 是 5 分钟,下一次请求就要按全价重新计算整个前缀。一个思路是在快过期时发一个极小的请求“续期”,但续期本身也花钱。值不值得,要算期望收益。
pi 的做法按期望收益决定要不要给缓存续期:在 TTL 的 90% 处、预计能省 0.05 美元以上才发。coding-agent/src/core/cache-warmer.ts
/** A refresh is sent only when it is expected to save at least this many dollars. */ const CACHE_WARMING_MINIMUM_EXPECTED_SAVINGS = 0.05; /** Chance that a real request arrives before the cache entry expires while the agent sits idle. * Measured from our own usage; per-session estimates were not better than this constant. */ const IDLE_CONTINUATION_PROBABILITY = 0.15; /** Refresh at 90% of the TTL while preserving at least ten seconds of margin. */ export function getCacheWarmingDelayMs(ttlMs: number) { if (ttlMs <= 10_000) return undefined; return Math.max(1, Math.floor(Math.min(ttlMs * 0.9, ttlMs - 10_000))); }
- 在 TTL 的 90% 处续期,并至少留 10 秒余量。
- 只有预估能省下至少 0.05 美元才发续期请求;续期的用量计入会话费用,但不进入模型上下文。
- 空闲时“用户会继续”的概率取常量 0.15,注释说这是从自己的使用数据里测出来的,按会话单独估计并没有更准。用数据定参数、并记录为什么不用更复杂的方案,这是很好的工程习惯。
- 还有一个细节:某些模型的“思考预算”由 max_tokens 推导,而缓存键包含它;用 1 个 token 的请求续期会改变缓存键,等于白续期。pi 对这种情况直接判定不可续期(
isReplayable)。
一个 40 turn 的任务,平均每次请求 60k token 输入。不缓存:总输入 2.4M token。如果每轮只有约 3k 是新增内容、其余都命中缓存,且缓存读取价格按正常输入的一成计算,等效输入约为 40 × (3k + 57k × 0.1) ≈ 0.35M,降到原来的约七分之一。(具体价格以各厂商定价为准。)
Prompt caching 的原理?怎么提高命中率?
- 原理:缓存请求前缀的 KV 计算结果,前缀完全一致才命中,有 TTL。
- 设计:稳定内容在前;历史只追加;不在前缀放时间戳;工具集变化用增量表达;会话粘性路由。
- 监控:缓存命中率是关键的过程指标,命中率突降往往意味着有人改动了前缀。但它不是目标本身:命中率很高,也可能只是每轮都在重发用不上的上下文。
如何降低 agent 的总成本?
- 先度量:目标指标是每个合格完成的任务花了多少钱(失败、返工的花费也算进去);过程指标是每任务 token、turn 数、缓存命中率。
- 前缀缓存(通常收益最大)。
- 减少输入:截断、渐进披露、压缩、子 agent。
- 模型路由:分类、摘要交给小模型。
- 减少 turn:更好的工具和错误信息;可并行的调用一次发出。
- 每项改动都跑评测,确认成功率没降。
- 已有证据长会话里,前缀缓存是影响成本最大的因素之一(Manus 称命中率是生产 agent 最重要的单一指标)。但它是过程指标,更接近目标的是完成一个合格任务的总成本:缩短无用上下文可能让命中率下降,总成本却更低。
- 已有证据“不要改前缀”已经出现在多处设计里:厂商 API、MCP 规范、工具按需加载(被搜到的工具定义出现在对话后部,而不是改动开头的工具列表)都在照顾它。
- 作者判断缓存会提供更多显式控制(更长 TTL、主动预热、跨会话共享前缀)。pi 的“按期望收益预热”这类逻辑可能部分转到厂商那边。
工具设计与实现
基础设计原则(ACI)
工具是模型的 API。SWE-agent 论文(2024)把它叫作 ACI(Agent-Computer Interface),Anthropic 在《Building Effective Agents》里沿用了这个说法。它值得像设计对外 API 一样用心:
- 少而通用:coding agent 有 read、write、edit、bash 就能做几乎所有事。工具越多越容易选错,定义本身也占上下文。
- 描述写给模型:做什么、什么时候用、什么时候不用、参数含义。
- 错误是反馈:告诉模型错在哪、怎么改。
- 防呆:让模型不容易犯错。
- 控制输出体积:默认截断,告诉模型完整输出在哪。
- 标注副作用:只读、破坏性、幂等、是否与外部世界交互,供权限系统参考。
| pi 内置工具 | 参数 | 默认启用 |
|---|---|---|
read | path, offset?, limit? | 是 |
bash | command, timeout?(秒) | 是 |
edit | path, edits: [{oldText, newText}] | 是 |
write | path, content | 是 |
grep find ls | …(grep、find 遵守 .gitignore;ls 不过滤) | 否 |
powershell | …(仅原生 Windows) | 否 |
进阶edit:为什么用文本替换,怎么防呆
用行号修改会遇到“行号漂移”:第一处修改增加了 3 行,第二处的行号就错了。用“旧文本 → 新文本”替换,位置由内容决定,不受影响。但它有两个新问题:模型复述的旧文本和文件不完全一致(空格、引号),以及旧文本在文件中出现多次。试试 pi 的处理方式:
pi 的做法先精确匹配,再做归一化后的模糊匹配;出现多次就报错,要求给出能唯一定位的文本。coding-agent/src/core/tools/edit-diff.ts · fuzzyFindText()
- 先精确匹配;找不到再做模糊匹配:Unicode NFKC 归一化、去掉每行行尾空白、弯引号转直引号、各种破折号转
-、特殊空格转普通空格。 - 出现多次就报错,并明确要求“提供更多上下文使其唯一”。
- 多处修改放在一次调用的
edits[]里,每个 oldText 都对原始文件匹配,不能重叠,然后一次性应用。 - 替换后内容没变也报错:这通常意味着特殊字符问题,提示模型检查。
编辑格式和模型是绑定的。厂商会在自家 harness 的工具上做强化学习,于是模型对某种编辑格式特别熟练:OpenAI 建议 GPT 系列和 Codex 模型使用 apply_patch(一种类 diff 的补丁格式),Anthropic 的模型则针对 str_replace 式的文本编辑工具训练过。同一个模型换一种它不熟悉的编辑格式,出错率会明显上升。
所以支持多厂商的 harness 要考虑按模型选工具格式,而不是全部用一种。这是第 2 章“harness 也是训练环境”带来的直接后果。
进阶动作接口的精度光谱
同一件事,可以通过精度不同的几种接口去做。比如“把图表导出成 PNG”:
| 接口 | 模型做什么 | 优点 | 代价 |
|---|---|---|---|
| GUI(computer use) | 看截图 → 点坐标 → 再看截图 | 任何有界面的软件都能用 | 慢、费 token(每步一张图)、容易点错 |
| CLI | export --format png --output fig.png | 精确、可组合、模型训练时见过大量用法 | 软件得有命令行;要读退出码和错误输出 |
| API / 函数调用 | 输出带 JSON 参数的调用 | 参数可校验,可做权限标注 | 要有人先写好工具定义 |
| 代码(code mode) | 写一段脚本批量调用 | 中间结果不进上下文 | 需要沙箱执行代码(第 16 章) |
原则:能用更精确的接口,就不用更模糊的。GUI 留给没有任何接口的软件。也有项目反过来,给只有界面的软件自动生成一层命令行,让 agent 能走 CLI。无论走哪种接口,最后都要检查结果:退出码、输出、生成的文件,或者再截一张图确认。
Computer use / GUI agent仍在演变能看屏幕、点鼠标的 agent;有接口时优先走接口
- Anthropic 推出 computer use:模型看截图,输出鼠标和键盘动作。
- OpenAI 推出 Operator,在浏览器里替用户操作网页。
- OSWorld 等基准用来衡量这类能力;coding agent 则普遍选择 CLI 和浏览器自动化脚本,而不是像素级点击。
和其他词的关系:它和 function calling、CLI 回答的是同一个问题:输出怎么变成动作。区别只在接口精度。点击类操作同样需要“确认后再执行”这类护栏(第 13 章)。
深入截断策略、进程树与并发写
pi 的做法读文件保留开头、命令输出保留结尾;超时杀整个进程树;同一文件的修改排队执行。tools/truncate.ts · tools/bash.ts · tools/file-mutation-queue.ts
- 截断方向不同:上限是 2000 行或 50KB,先到为准,一般按整行截断(例外:bash 输出的最后一行本身就超过字节上限时,只保留这一行的末尾,并标明是半行)。read 用
truncateHead(保留开头,模型可以用 offset 接着读);bash 用truncateTail(保留结尾,因为编译错误、测试失败、退出信息都在最后)。bash 被截断时,完整输出写入临时文件,结果里告诉模型路径和起始行号。 - 杀进程树:bash 在非 Windows 上以
detached启动成独立进程组;超时或用户中止时调用killProcessTree(pid),连同所有子进程一起结束。超时没有默认值,由模型按需传入。 - 同文件串行、不同文件并行:并行执行的两个 edit 如果改同一个文件,会出现“读-改-写”竞争,后写的覆盖先写的。
withFileMutationQueue按文件的 realpath(解析符号链接,防止两条路径指向同一文件)维护一条 Promise 链,同一文件的操作排队,队列清空后删除 key 防止内存泄漏。
final class FileMutationQueue { private final ConcurrentHashMap<Path, ReentrantLock> locks = new ConcurrentHashMap<>(); <T> T withLock(Path file, Callable<T> op) throws Exception { Path key = realPathOrAbsolute(file); // 符号链接指向同一文件时必须用同一把锁 ReentrantLock lock = locks.computeIfAbsent(key, k -> new ReentrantLock(true)); // 公平锁 = 按到达顺序 lock.lock(); try { return op.call(); } // 完整的 读 → 改 → 写 都在锁内 finally { lock.unlock(); if (!lock.hasQueuedThreads()) locks.remove(key, lock); // 清理,防止 map 无限增长 } } }
注意 Java 版的清理有一个竞态:remove 之后、另一个线程刚好 computeIfAbsent 拿到新锁,与仍持有旧锁引用的线程并发。生产代码可以用引用计数,或用 Guava 的 Striped<Lock> 固定数量的锁来避免。
让 agent 修改文件,用行号还是文本替换?各有什么问题?
- 行号:多处修改后漂移;模型数行号容易错。
- 文本替换:位置稳定;但要处理空白和引号差异(模糊匹配)、多次出现(要求唯一)、多处修改(基于原文件匹配、不可重叠、一次应用)。
- 整文件重写:最简单,但费 token 且容易误删内容,只适合新文件。
工具输出很大(比如构建日志)怎么处理?
- 按行数和字节双上限截断,尽量按整行截;单行本身超长时才截半行,并明确标出。
- 截断方向按场景选:日志保留尾部,文件保留头部。
- 完整内容落临时文件,结果里告诉模型路径和截断位置,需要时再读。
并行工具调用改同一个文件会怎样?怎么解决?
- 两个调用都读到旧内容,各自修改后写回,后写的覆盖先写的,丢失更新。
- 按文件真实路径加锁,锁住完整的读-改-写;不同文件仍然并行。
- 或者把有共享状态的工具声明为串行执行。
- 已有证据Claude Code、Codex CLI、pi 等主流 coding agent 都以“少量通用工具 + bash”为主。专用工具的价值主要集中在两类:模型训练时见过的格式(如编辑工具),以及需要权限控制、结构化输出的操作。
- 已有证据工具数量多时,已有厂商 API 和 harness(包括 pi)支持按需加载工具定义,不必全量放进上下文(第 16 章)。
- 作者判断工具格式和模型的绑定会加深。harness 的工具层会出现“按模型适配”的一层,类似 JDBC 驱动按数据库适配方言。
- 作者判断防呆逻辑(模糊匹配、唯一性检查)会变薄:模型复述旧文本越来越准。但截断、进程树、并发写这些“替模型做事”的逻辑不会变。
记忆与检索
本章分两个单元。单元 A · 规则、技能与记忆:agent 自己的项目约定、做事方法和经验怎么存、怎么加载、怎么更新。单元 B · 知识检索:外部的大量文档怎么找到并放进上下文。最后用一张表把两边放在一起比较。
基础三层记忆
| 类型 | 是什么 | 典型实现 |
|---|---|---|
| 工作记忆 | 当前上下文窗口里的内容 | messages + 压缩 |
| 会话记忆 | 一次会话的完整记录,可以恢复和分支 | 只追加的事件日志(第 10 章) |
| 长期记忆 | 跨会话的知识、偏好、项目约定 | 规则文件、笔记、数据库、向量库 |
按时间跨度看,会话记忆和长期记忆之间还夹着一层:只属于这个任务、但要跨会话保留的进度,比如交接摘要、todo 清单、功能清单。用户说“接着做我的作业”,靠的就是它。第 9 章的长时任务主要靠这一层。
进阶长期记忆怎么写
长期记忆的写入比读取更难:
- 谁来写:模型自动写容易积累错误,人工确认又太慢。常见折中是模型提议、人确认,或只允许写入特定文件。
- 记忆污染:被注入的恶意内容如果写进长期记忆,会影响之后所有会话。写入前要校验来源。
- 过期与冲突:项目约定会变,记忆要能更新和失效。更正一条旧记录时,新记录要带上来源、更新时间和“取代了哪条”,否则两条矛盾的记录会并存,模型不知道信哪个。
- 存储方式不决定检索方式:同一份文本笔记,可以用关键词搜(适合 ID、日期),也可以用向量搜(适合换了说法的问题),或者两者混合。先用文件存,检索方式以后可以换。
- 定期整理:原始历史越积越多。有的 agent 会定期把历史对话压成几条持久的事实和偏好,写进记忆文件,原始记录留着备查。
Agent memory仍在演变在 coding agent 里,从“向量库存对话”转向“文件 + 模型自己读写”
- MemGPT 论文:借鉴操作系统的分页,把上下文当内存、外部存储当磁盘,由模型自己换入换出。后来发展成 Letta。
- ChatGPT 上线跨会话记忆;各类“记忆层”产品多用向量库存储和检索对话片段。
- Anthropic 推出 memory tool:记忆就是一个目录里的文件,模型用查看、创建、修改等操作自己管理。
- coding agent 普遍用 CLAUDE.md / AGENTS.md、skills、进度文件做长期记忆,全部是文件。
趋势:文件胜出的原因很工程:人能直接读和改、能进 git、能审计、模型天然会用。向量库仍适合海量非结构化内容的召回。注意范围:这说的是 coding agent 这类能读写文件的场景;聊天产品的跨会话记忆多由厂商托管,存储和检索方式并不公开,不能一概而论。
深入pi 的规则文件与 skill:文件 + 渐进披露
pi 的做法不建向量索引;规则文件逐级向上发现;skill 只放名字和描述,全文按需读取。docs/configuration.md · core/skills.ts · formatSkillsForPrompt()
- 不建向量索引。pi 让模型用 bash(rg、find)或可选的 grep / find 工具搜代码。
- 规则文件分层发现:从 agent 目录(
~/.pi/agent)、当前目录以及每一级父目录收集AGENTS.md(或CLAUDE.md),放进 system prompt 的project_context段。monorepo 里子目录可以有自己的规则。 - skill 渐进披露:启动时只把每个 skill 的名字、描述、文件路径放进
<available_skills>,并告诉模型“任务匹配时用 read 工具读取 skill 文件”。全文只在需要时才进上下文。
Skills(Agent Skills)正流行2025 年 10 月出现,两个月成为开放标准,被认为“可能比 MCP 更重要”
- Anthropic 推出 Skills:一个文件夹,里面一份带 name / description 头信息的
SKILL.md,加上可选的脚本和资料。只有描述常驻上下文,正文按需读取。 - Simon Willison 撰文认为它可能比 MCP 影响更大:简单到只是 Markdown,任何能读文件的 agent 都能用。
- 规范以开放标准发布(agentskills.io),OpenAI Codex、VS Code 等很快支持。
- 支持它的工具从十几个增长到数十个,也出现了技能市场和随之而来的供应链安全问题(第 13 章)。
和其他词的关系: vs AGENTS.md:规则文件每次都加载,讲“这个项目的约定”;skill 按需加载,讲“某类任务怎么做”。 vs MCP:MCP 解决“能连上什么系统”,skill 解决“该怎么做”;一个 skill 里可以写“用这个 CLI 或这个 MCP 工具完成某步”,二者是互补的(第 16 章)。 vs 渐进披露:skill 就是渐进披露这个思路的标准化包装。
进阶RAG vs Agentic Search
| 经典 RAG(预建索引,请求前固定检索) | Agentic search(模型现场多轮搜原始文件) | |
|---|---|---|
| 前期成本 | 切块、建索引(向量检索还要 embedding)、维护索引同步 | 几乎为零 |
| 查询成本 | 一次检索,便宜、快 | 多轮工具调用,慢、贵 |
| 准确性 | 取决于切块、检索和重排:向量检索擅长“说法不同、意思相近”,不擅长精确标识符;切块会切断上下文 | 精确字符串定位好,能顺着引用继续找;但也可能漏搜,取决于查询怎么写 |
| 新鲜度 | 依赖索引更新 | 直接读当前文件,天然最新 |
| 适合 | 海量文档、知识库问答 | 单个代码仓库、中等规模 |
二者可以结合:把检索包装成一个工具(search_docs),由模型决定查不查、查几次、换不换关键词,这叫 agentic RAG。RAG 自身的常见优化:混合检索(BM25 + 向量)、rerank、查询改写、按文档结构切块。
RAG仍在演变从“LLM 应用的标准架构”降级为 agent 手里的一个工具
- Facebook AI 的论文提出 Retrieval-Augmented Generation。
- ChatGPT 之后,“切块 + embedding + 向量库 + 拼进提示词”成为几乎所有 LLM 应用的标准架构,向量数据库融资火热。
- 长上下文出现,“RAG 已死”的争论开始;Anthropic 提出 Contextual Retrieval(切块前给每块补上下文),说明 RAG 本身也在改进。
- Claude Code 等 coding agent 放弃向量索引,改用 grep / find 的 agentic search,并公开说效果更好。
现状:没有死,但位置变了。在 agent 里,检索是模型可以选择调用的工具之一(agentic RAG),而不是每次请求前固定执行的流水线。海量文档的企业知识库仍然离不开它。
进阶检索管线:RAG 要做好得过哪几关
企业知识库问答绕不开 RAG。一条管线依次是:收集 → 清洗去重 → 切块 → 建索引 → 检索 → 排序 → 生成答案。常见问题几乎都出在中间几步:
| 环节 | 常见问题 | 做法 |
|---|---|---|
| 切块 | “截止 11 月 27 日”被切出来后,不知道是哪门课、哪个作业 | 按标题切;每块带上“地址”:文档、章节、页码、版本 |
| 检索 | 用户说“什么时候交”,文档写“提交截止”;向量能匹配,关键词不行。反过来,get_user 和 get_users 只差一个字母,向量分不清 | 混合检索:BM25 关键词 + 向量,用 RRF 按排名合并两路结果;分词时保持标识符完整 |
| 排序 | 两门课都有“期末项目截止”,相似度差不多 | 先召回几十条候选,再用 reranker 对“完整问题 + 候选”逐条打分 |
| 生成 | 塞进去的旧通知、重复副本、别的课程反而把答案淹没 | 只放筛过的证据,控制条数;找不到就说找不到,并给出出处 |
有些问题不是找一段话就能回答的,比如“想做 AI 研究,该按什么顺序选课?”答案要把“数学 → 机器学习 → NLP”几段关系串起来。这时可以在文本块之外再建一张实体关系图,检索时沿关系找,这就是 GraphRAG。代价是建图要大量调用模型抽取实体,抽错了还会引入不存在的关系,内容更新时要同步改图。
RAG 怎么评测(召回率、排序质量、答案是否有出处、出处是否最新)放在第 15 章。
GraphRAG仍在演变适合关系类、全局类问题,代价是建图和维护
- 微软发表 GraphRAG:用模型从文档抽取实体和关系,再按社区做摘要,回答“整个文档集的主题是什么”这类全局问题。
- 港大 LightRAG 等轻量方案出现:图和向量一起存,按实体和关系两级关键词检索,降低建图和更新的成本。
- 在需要多跳推理的领域(金融关联方、法规、课程体系)使用较多;单个事实的查询仍以普通 RAG 为主。
和其他词的关系:它没有换问题,只换了索引的结构。判断用不用它,只看问题是“找一个事实”还是“把几个事实连起来”。
进阶两个单元合起来:规则、skill、工具搜索、RAG、记忆
这几个词常被当成五样不同的东西,其实回答的是同一个问题:什么内容、什么时候放进上下文。区别只落在四个维度上:
| 放的是什么 | 常驻还是按需 | 谁决定放 | 怎么找到 | |
|---|---|---|---|---|
| AGENTS.md | 项目约定 | 常驻 | harness | 按目录层级发现 |
| Skills | 做事方法 | 索引常驻,正文按需 | 模型 | 看描述,读文件 |
| Tool search | 工具定义 | 搜索入口常驻,定义按需 | 模型 | 搜索工具 |
| RAG | 外部知识 | 按需 | harness 固定执行,或模型调用 | 向量 / 关键词 / 图 |
| Agentic search | 代码、文档 | 按需 | 模型 | grep、find、读文件 |
| 记忆文件 | 偏好、进度、经验 | 摘要常驻,细节按需 | 模型读写,harness 管权限 | 读文件,或搜索历史 |
下次再冒出一个“X 按需加载”的新词,先填这四列,就知道它和哪个老词只差一格。
Agent 的记忆怎么设计?
- 三层:工作记忆(上下文 + 压缩)、会话记忆(事件日志)、长期记忆(文件 / 结构化存储 / 向量库)。
- 读取用渐进披露;写入要控制权限、防污染、可过期。
RAG 和 agentic search 怎么选?
- 按规模、变化频率、精确性要求对照上表。
- coding 场景常用 agentic search;企业知识库常用 RAG;二者可通过“检索即工具”结合。
Skills 和 AGENTS.md、MCP 有什么区别?
- AGENTS.md:总是加载的项目规则,“在这里要遵守什么”。
- Skill:按需加载的做事方法,“遇到这类任务怎么做”,可以附带脚本。
- MCP:连接外部系统的协议,“能访问什么”。
- 三者叠加使用:规则定边界,skill 给方法,MCP 或 CLI 给能力。
- 已有证据在 coding agent 里,文件是长期记忆和项目知识的主要载体:规则文件、skills、进度文件、Anthropic 的 memory tool 都是文件。
- 已有证据Claude Code、pi 等 coding agent 以 agentic search 为主。Cursor 现行文档也写明,它的 Agent 用本地的 Instant Grep 加 Explore 子 agent 搜代码,不存储代码库的 embedding(Cursor 文档,2026-10 查阅)。海量文档的知识库问答仍以 RAG 为主。
- 作者判断记忆的自动写入会普及(agent 自己总结经验写进文件),配套的是审核、过期和防污染机制。
- 有争议skill 会不会像当年的插件市场一样膨胀失控。格式简单是优点,也意味着质量和安全没有门槛。
规划、自我验证、多 Agent 与长时任务
这一章有两条主线。一条是怎么分工:规划、自我验证、多 agent。另一条是怎么跨上下文和故障接着干:长时任务,建议接着读第 10 章(状态恢复)和第 14 章(可靠性),三章讲的是同一条线。“自我改进”一节是延伸内容,第一遍可以跳过。
基础三种提升成功率的结构
- 规划:先产出计划再执行,可审查、不易跑偏。
- 自我验证:改完跑测试、调完查结果。给 agent 一个能验证结果的手段,往往是提升成功率最有效的一步。
- 多 agent:主 agent 把子任务交给独立上下文的子 agent,子 agent 只返回结论。
进阶多 agent 的模式与代价
| 模式 | 结构 | 适合 |
|---|---|---|
| 编排者-执行者 | 主 agent 拆任务,派给子 agent,汇总 | 可拆成独立子问题的调研、批量修改 |
| 流水线 | 侦察 → 规划 → 执行 → 审查,各阶段专职 | 流程清晰的开发任务 |
| 评估者-优化者 | 一个生成,一个按标准评审,循环改进 | 有明确评价标准的产出 |
多 agent 的收益是上下文隔离和并行;代价是 token 成倍增长、子 agent 间信息不共享导致决策冲突、调试和评测更难。改同一模块代码这种强耦合任务,单 agent 往往更好。
Multi-agent仍在演变从“一群 AI 角色扮演开会”演变成“子 agent 做上下文隔离”
- AutoGen、CrewAI、MetaGPT 等流行“产品经理 agent + 架构师 agent + 程序员 agent”的角色分工,演示好看,实际效果不稳定。
- 同一周两篇文章:Cognition 发表《Don't Build Multi-Agents》,认为子 agent 之间信息不共享会导致决策冲突;Anthropic 发表多 agent 研究系统的经验,在调研类任务上明显优于单 agent,但 token 消耗是普通对话的约 15 倍。
- Claude Code 等支持自定义子 agent;较多团队的做法逐渐趋同:读多写少、可并行的任务(调研、搜索、审查)用子 agent;强耦合的写操作留在一个 agent 里。
- Kimi K2.5 推出 agent swarm:用强化学习只训练协调者、子 agent 不动,由模型自己决定拆分和派发。分工的决策开始被训练进模型(第 2 章)。
两篇文章其实不矛盾:一个讲写代码(强耦合),一个讲调研(可并行)。“多 agent 好不好”这个问题本身问错了,要问的是任务能不能拆成上下文独立的子问题。
进阶多 agent 的三类协调失败
拆开以后,问题出在“合”的时候。三类最常见的失败和对策:
| 失败 | 例子 | 对策 |
|---|---|---|
| 分派含糊 | “发布前审一遍这个应用”:三个子 agent 都去看了登录,没人看支付 | 按维度明确分工(安全 / 性能 / 测试),规定返回格式:结论 + 证据 |
| 上下文被淹没 | 子 agent 把完整日志和文件内容都交回来,主 agent 读完忘了目标 | 只回传结论和证据链接,完整轨迹留在子 agent 那边,需要时再取 |
| 各自完成,拼不起来 | 前端期待 access_token,后端返回 token。两边都报告“完成”,用户还是登录不了 | 先定接口契约再分头做;最后由主 agent 跑端到端测试,而不是相信各自的“完成” |
第三类最说明问题:子任务各自的验证都通过了,整体还是错的。所以判断完成的验证器要放在“合”的那一层(第 15 章)。
进阶长时任务:跨越多个上下文窗口
任务跑几个小时,往往要经历多次压缩甚至多次全新会话。每个新会话就像换班的工程师,不记得上一班干了什么。Anthropic 2025 年底的《Effective harnesses for long-running agents》给出了一套被广泛借鉴的做法:
- 初始化 agent 只跑一次:把需求拆成一份功能清单(
feature_list.json,每项标记通过 / 未通过),写好启动脚本init.sh,建立进度日志,做第一次 git 提交。 - 后续每个会话固定流程:读进度日志和
git log→ 跑启动脚本确认程序还能用 → 挑一个未通过的功能实现 → 端到端测试(用浏览器自动化像用户一样点) → 更新清单和日志 → 提交。 - 功能清单用 JSON 而不是 Markdown:原文的解释是模型不太会随意改写或覆盖 JSON 文件,而 Markdown 容易被“顺手整理”。
- 针对的失败模式:过早宣布完成、留下半成品状态、没测就标记完成、新会话不知道怎么启动项目。
这套做法的本质是:把记忆从上下文搬到文件和 git 里,每个会话都能从磁盘重建现场。它和第 10 章的“事件日志恢复”是同一思路在更高一层的应用。
并行的 agent 用 git worktree 隔离。同时跑多个 agent 改同一个仓库时,各自一个 worktree(同一仓库的多个工作目录),互不干扰,最后各自提交或合并。这已是 Claude Code、Codex 等工具的常见做法。
深入延伸 · 自我改进:把验证过的经验写回文件
agent 做完一件事,能不能下次做得更好?不改权重也可以:把这次学到的东西写回 skill 或记忆文件,下次加载时就用上了。比如一个做幻灯片的 skill,某次渲染后发现图片压住了标题,就给 SKILL.md 加一条“图片放在标题下方,每次修改后重新渲染检查”。
有的项目把这类更新分成三种:修复现有 skill、从现有 skill 派生出适合新任务的版本、从一次成功的过程里新捕获一个 skill。做 skill 检索的项目则用 BM25 先召回候选、再用向量重排,从上百个 skill 里挑出合适的那个。
这件事的本质是第 ⑥ 个问题“验证”:同一个验证信号,用在不同的地方。
| 验证信号用在哪 | 改变的是什么 | 见 |
|---|---|---|
| 运行中 | 这一次的下一步:测试没过就接着改 | 本章基础 |
| 离线评测 | 你的判断:这个改动该不该上线 | 第 15 章 |
| 写回文件 | 下一次的上下文:skill、记忆、规则 | 本节 |
| 训练 | 模型权重 | 第 2 章 |
写回文件的风险和长期记忆一样:错误经验会污染以后所有任务,被注入的内容也可能借此长期驻留(第 13 章)。工程上的做法:每次更新都留版本;更新要在另一个任务上验证有效才保留,避免只对这一次有用;来路不明的 skill 先审再用。
Skill 自进化正流行把验证过的经验写回 skill 和记忆文件
- Voyager 在《我的世界》里让 agent 把成功的动作写成代码技能库,后续任务直接调用,这是“技能库”思路的早期代表。
- Skills 成为标准格式后,让 agent 自己写、自己改 skill 变得很自然。
- 出现专门做 skill 检索、修复、派生和版本管理的开源项目,“自进化”成为 agent 课程和研究的独立主题。
和其他词的关系:它和强化学习是同一个回路(尝试 → 验证 → 改进),只是改的是文件而不是权重,所以更快、可读、可回滚,也更容易被污染。
深入pi 怎么在核心之外实现它们
pi 的做法子 agent 就是再起一个 pi 子进程;计划模式是一个禁用写工具的扩展。examples/extensions/subagent · examples/extensions/plan-mode
- 子 agent = 子进程:每次调用子 agent,扩展都
spawn一个新的pi --mode json -p --no-session进程,逐行解析它的 JSON 事件流。上下文天然隔离,崩溃也互不影响;中止时 Ctrl+C 会传播并杀掉子进程。 - 角色用 Markdown 定义:
scout(快速侦察,返回压缩后的上下文)、planner(出计划)、reviewer(审查)、worker(全能执行)。 - 流水线用模板串起来:
implement= scout → planner → worker;implement-and-review= worker → reviewer → worker。 - 计划模式:禁用 edit / write,bash 只允许只读命令白名单;从回复里的
Plan:段提取编号步骤,执行时用[DONE:n]标记追踪进度,状态随会话持久化。
这说明了一个架构观点:只要核心提供“事件 + 工具注册 + 进程级复用”,规划和多 agent 都可以作为插件实现,不必塞进内核。
什么时候该用多 agent?
- 子任务独立、可并行、会产生大量中间信息时用。
- 强耦合、需要共享大量上下文时不用。
- 工程要点:子 agent 的输入输出要有契约,限制它的工具和预算;子 agent 用进程或独立会话隔离。
一个任务要跑好几个小时,跨了多次上下文窗口,怎么保证不跑偏?
- 把状态放在上下文之外:功能清单(JSON)、进度日志、git 提交历史。
- 每个会话固定开场:读进度 → 冒烟测试 → 只做一项 → 端到端验证 → 提交。
- 完成的判定交给测试,而不是模型的自我宣布。
- 已有证据多家厂商的实践建议都把“给 agent 一个验证手段”列为提升成功率最有效的手段之一。任务越长,越不能靠人一直看着,它的分量只会更重。
- 已有证据公开的工程文章(Anthropic、Cognition 等)多把子 agent 的价值归于上下文隔离和并行;“产品经理 + 程序员”式的角色扮演分工在 coding agent 产品里已经少见。
- 作者判断强制规划、强制更新待办清单这类脚手架会变薄。Claude Code 团队提过,一个防止模型“谎称重构完成”的 todo 强制机制,在他们的新模型上已经没有必要;这是单一团队、单一模型的观察,换模型前仍要评测。用文件记录进度、用测试判定完成这类外部状态不会变薄。
- 作者判断多个 agent 并行干活、人来分派和验收会成为常见工作方式,配套的是 worktree 隔离、任务队列和 review 流程(第 17 章)。
- 作者判断agent 把经验写回 skill 和记忆会普及,配套的是版本、跨任务验证和审核,和代码合并的流程越来越像。
- 有争议分工的决策会多大程度被训练进模型。已有厂商在训练协调者,效果还有待更多验证。