Agent原理介绍

题图

如 雪落声 将点点落向无名策,
城墙冷冷反光 残局揭果 必须得成望,
走着走着 略过规则,
方圆自成 墨染天地之格,
走着走着 只用双瞳的眼色,
看山河的颜色。

—— 无名策 · 塞壬唱片-MSR/拾贰 12/黎明鑫(阿火)/Mi/骅翎

一个 while 循环的全部设计负担

题图:望送给大家的小道具「形制朴素的棋具」(图源)。背景那盘谱出人机大战第四局——李世石执白,在全盘劣势下第 78 手弈出”神之一手”,就是图中 L11 上被两颗黑子夹住的那颗白子;此后 AlphaGo 如何,用望的话说,”昏招频出”。人类棋手在绝对未知的强大面前,凭借着直觉、灵感和不屈的意志,完成的最后一次、也是唯一一次伟大反击。

本文只谈抽象原理:循环为什么能干活、设计时有哪些选择、每个选择的代价。具体实现一律作为佐证引用——例子取自 pi(v0.84.4),文中每一处 file.ts:line 都直接链到该 tag 下的具体行;但它只是参照物,换掉这套 harness,本文每一条仍然成立。

引子

时值”Agent”这个词已经从技术名词滑向营销形容词之际,把它的话术剥干净,里面只剩三样东西:一个会输出函数的语言模型、一段真的去执行函数的宿主代码、以及把执行结果送回模型的那次回灌。剩下的都是配套设施。回望 2023 年那个”ReAct 提示词 + 一个 for”的原始形态,今天它已经长成带树形会话、逐钩子扩展、数十家 provider 的 runtime——同一样东西的两种活法。谈设计约束是很有必要的。

作为读代码比读论文习惯的我,本文不谈该选哪家模型、不谈 prompt 措辞玄学、不跑 benchmark,只谈这一圈代码在设计上必须注意的问题。

最小形态与三条前提

整个 agent 压到七行伪码:

1
2
3
4
5
6
7
messages := [user(prompt)]
loop:
resp := LLM(system, messages, tools) ← ① 调模型
calls := resp 里 type == toolCall 的那些 ← ② 取调用意图
if calls 为空: break ← ③ 判停
results := 执行 calls ← ④ 执行
messages := messages + [resp] + results ← ⑤ 回灌与落盘

这五格就是全文的目录,下面逐格展开。而它成立的前提是三条客观约束:

前提一 · 终止条件由模型的行为给出。 宿主代码里没有任何一行在判断”任务是否完成”,完成的表现是模型不再请求工具。控制流的出口条件出自一个概率模型,而不是一个可判定的谓词。

前提二 · 模型不能行动。 它能输出的只有结构化文本:”我要调用 X,参数是 Y”。动作由宿主代为执行,观察结果再还回去。因此能力边界等于 tools 列表,与知识边界无关。

前提三 · 模型无状态。 模型侧永远是 messages → resp 的纯函数,跨圈的连续性全靠宿主维护那个数组。”agent 记得刚才发生了什么”是宿主的性质。

三条压在五格上,问题的分布是这样的:

1
2
3
4
5
① 调模型    ← 前提三最贵(每圈重放全量)→ 两本账、前缀稳定性、提示装配
② 取意图 ← 前提二(只能说) → 工具契约、截断防御
③ 判停 ← 前提一(出口是概率性的) → 闸门、可验证性、共识退出
④ 执行 ← 前提二(手在宿主这边) → 调度、副作用锁、权限边界
⑤ 回灌落盘 ← 前提三(记忆在这里) → 存储形态、压缩

顺带一提,契约本身就长在这三条上。一个 runtime 的最小输入只有三样:系统提示、可见历史、可用工具。pi 的 AgentContext 恰好三个字段(agent/src/types.ts:413-420),多一个都没有:

1
2
3
4
5
export interface AgentContext {
systemPrompt: string; // 行为约束 → ①
messages: AgentMessage[]; // 可见历史 → ⑤
tools?: AgentTool<any>[]; // 能力边界 → ②
}

任何自称 agent runtime 的东西,只要输入能归约到这三项,就是同一个抽象。

一次 forward 的五个槽位

① 调模型:两本账与一条前缀

这一格要做的设计只有一件——决定喂进去什么。但”喂进去什么”有两本账,混在一起算是这个主题上最常见的错误。

第一本:窗口占用。 前提三把成本结构定死了。第 nn 圈的 prompt 长度不是”这一轮说了多少”,而是:

Pn=sys+tools固定前缀+i=1n1(respi+resulti)+本轮新增P_n = \underbrace{|\text{sys}| + |\text{tools}|}_{\text{固定前缀}} + \sum_{i=1}^{n-1}\Big(|\text{resp}_i| + |\text{result}_i|\Big) + |\text{本轮新增}|

其中每个 resulti|\text{result}_i| 通常远大于 respi|\text{resp}_i|——模型只说十个 token 的”请读这个文件”,工具回来的是两千行。与单次补全对比:

单次补全 agent
prompt 一次性 每圈重放全量
窗口占用 O(S)O(S) 累计 O(n2)O(n^2)
主导项 输出 工具返回值

第二本:钱。 上面那本账直接乘单价是错的。历史是 append-only 的,所以第 nn 圈的 prompt 里有一段与上一圈逐字节相同——长度正好是 Pn1P_{n-1}。provider 就靠这个做前缀缓存,输入于是被劈成两桶。pi 的 Usage 给每桶单列了字段(ai/src/types.ts:382-403),下面按源码声明顺序节选:

1
2
3
4
5
input: number;        // 未缓存输入(OpenAI 侧即 prompt_tokens − cacheRead − cacheWrite)
output: number;
cacheRead: number; // 命中前缀,折扣价
cacheWrite: number; // 本次写入缓存的前缀
cacheWrite1h?: number; // 其中按 1h 保留的写量,只有 Anthropic 报这一档

计价按桶分别落地(ai/src/models.ts:878-898):

$  =  pininput  +  preadcacheRead  +  pwritecacheWrite  +  poutoutput\$ \;=\; p_{in}\,\text{input} \;+\; p_{read}\,\text{cacheRead} \;+\; p_{write}\,\text{cacheWrite} \;+\; p_{out}\,\text{output}

折扣幅度因家而异,实测取自 pi 的模型价目(构建期由 ai/scripts/generate-models.ts 拉取、写入未入库的 providers/data/*.json,跑 npm run generate:models 复现,也可直接查已发布的模型目录 pi.dev/api/models(原始 JSON)逐条对):

provider pread/pinp_{read}/p_{in} pwrite/pinp_{write}/p_{in} 例(每百万 token 单价)
OpenAI 1/41/21/4 \sim 1/2 0(自动,写不上报) gpt-4.1:in 2 / read 0.5
Anthropic 1/101/10 1.25;1h 档 2×2\times sonnet-4-5:in 3 / read 0.3 / write 3.75
DeepSeek 1/501/1201/50 \sim 1/120 0 v4-flash:in 0.14 / read 0.0028

两本账一摆开,第一本的那个结论就得收口:

窗口占用是 O(n2)O(n^2),钱不是。

每圈真正按全价付的只有”上一圈新落盘的那一截”(input 那一桶)加上缓存写入(cacheWrite)。把增量记作 δn=PnPn1\delta_n = P_n - P_{n-1},设总共跑 NN 圈,则未缓存输入的全程累计是个望远镜和:n=1Nδn=PNP0\sum_{n=1}^{N}\delta_n = P_N - P_0,即末圈与首圈的窗口长度之差——线性。所以前面那句”主导项是工具返回值”要说清适用范围:它说的是窗口,不是账单;账单的主导项在 output 与被写坏的 cacheWrite 上。

于是设计目标从”少喂点”变成一个精确得多的命题:agent 的上下文工程,目标是少破缓存。

什么会毁掉前缀。 一次全量 miss 的代价是 pinPnp_{in}\,P_n,命中时同一批 token 只按 preadPnp_{read}\,P_n 计;两式相除正是上表 pread/pinp_{read}/p_{in} 那一列,一到两个数量级。量级差在这里,所以毁前缀的动作要单独列一张表:

动作 后果 口径出处
中途换模型 / 换 provider 前缀整体作废 modelChanged 单列为一种 miss(cache-stats.ts:22:88)
改 systemPrompt、工具描述、工具列表顺序 从改动点往后全部失效
固定前缀里放时间戳、随机 id、git 状态 每圈都 miss
压缩 重写历史 ⇒ 压完那一圈按全价重付一次 省窗口、花一次钱,别频繁压
空闲超过 TTL 缓存自己过期,与代码无关 CACHE_TTL_MS = 5min,Anthropic 默认(cache-stats.ts:8)
单圈小幅抖动 误报 NOISE_FLOOR_TOKENS = 1024 以下不计(cache-stats.ts:11),因为那只是 cache breakpoint 的粒度

pi 把这件事做成了一份事后审计:拿上一圈的 prompt 长度与本圈的 cacheRead 相减,算出白跑了多少 token、多付了多少钱(coding-agent/src/core/cache-stats.ts:70):

1
2
const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;   // :62
const missedTokens = Math.min(prev.promptTokens, promptTokens) - usage.cacheRead;

注意 promptTokens 是三个桶之和(cache-stats.ts:62)——窗口占用量与计价口径用的是同一个数。这一点紧跟着就有用:

缓存能压价,压不掉跨档。 calculateCost 选价格档时用 inputTokens,而它就是那个三桶之和(ai/src/models.ts:879-886):

1
2
3
for (const tier of model.cost.tiers ?? []) {
if (inputTokens > tier.inputTokensAbove && ...) rates = tier;
}

也就是说,把会话撑进长上下文加价的,恰恰是那些已经打过折的历史。打折的是单价,不打折的是长度。

而且档位是整单适用的——ModelCost 的注释原话 “The highest matching input threshold applies to the full request”(ai/src/types.ts:816)。一跨过阈值,连那一大段命中缓存的前缀也一起换价:已发布目录里实测 gpt-5.4 于 272K 处 in 由 2.5 跳到 5、read 由 0.25 跳到 0.5,两桶同时 ×2。

最后解释一下凭什么 preadp_{read} 相对 pinp_{in} 能低一到两个数量级(比值见上表):这就是《KV Cache 笔记》里那件事的跨请求版本——命中的那段前缀,它的 K/V 张量还留在存储里,不必重算。所谓 prompt caching,物理上就是把 KV cache 的生命周期从”一次请求内”延长到”请求之间”。前缀必须逐字节相同、写缓存要额外付一次、窗口占用与计费同源,三条性质全部对得上。

装配顺序同时也是缓存边界。 有了上面那张表,”固定的在前、易变的在后”就不是排版偏好,是钱——Current working directory 这类运行时注入必须在末尾,正是为了让前面的长前缀保持稳定。可用的装配顺序大致固定:

1
2
3
4
5
6
角色与任务                  ← 固定文案          ┐
可执行动作的清单与说明 ← 由注册工具生成 │ 稳定前缀,应全程命中
行为准则 ← 可枚举、可推导 │
外部知识入口(技能索引) ← 只给索引 ┘
项目/环境上下文 ← 从文件系统读进来 ┐ 半稳定:改了项目文件才变
当前位置 / 工作目录 ← 运行时注入 ┘ 必须垫到最后

pi 的初始 prompt 实物。 上面那张顺序表太空,直接量一份真实的。下面九块就是本次会话正在用的系统提示——块界来自 coding-agent/src/core/system-prompt.ts:128-166 的拼装代码,体积取自实际落盘的文件:

1
2
3
4
5
6
7
8
9
① 角色句           "You are an expert coding assistant operating inside pi…"        169 B  固定
② Available tools ${toolsList}:默认 read / bash / edit / write 运行时 换工具集才变
③ 过渡句 "…you may have access to other custom tools depending on…" 99 B 固定
④ Guidelines ${guidelines}:逐条 addGuideline() 攒 运行时 换工具集条件生成
⑤ 文档路由块 Pi documentation:8 行 7 条指路、点了 14 个 .md 1024 B ≈256 token 固定
⑥ appendSection 配置级
⑦ <project_context> <project_instructions path="…">AGENTS.md 全文</…> 908 B 用户会改
⑧ <available_skills> 六条 name / description / location 6455 B 装包才改
⑨ Current working directory: <cwd> 54 B 会话内恒定

三条读法。

最大的固定块居然是”自家文档的路由表”。 ⑤ 那八行里七条全是”问到 extensions 就读 docs/extensions.md、问到 themes 就读 docs/themes.md“这类指路,一共点了 14 个文档路径,一份正文都不进 prompt——渐进披露不是只对第三方技能做的事,pi 对自家 README 照办(system-prompt.ts:138-145)。这段还是整份 prompt 里最划算的一块:纯静态,永远按 preadp_{read} 计。

尾部顺序决定失效半径,而它未必排对了。 两两比较的判据是:块 AA 排在 BB 前面更省,当且仅当

pAsA<pBsB\frac{p_A}{s_A} < \frac{p_B}{s_B}

其中 pp 是一次会话里该块的变化概率,ss 是它的体积——每字节变动率低的放前面。代入本项目的 ⑦⑧:⑦ 的体积只有 ⑧ 的七分之一,而人改 AGENTS.md 是随手的事、技能集只在装卸包时变,p/sp/s 明显是 ⑦ 更高,按判据这两块应当互换。现在的排法是改一次 AGENTS.md 就作废整个 ⑧,互换后改一次包只作废 ⑦。至于恒定却垫在最后的 ⑨——它只有几十字节,摆哪儿都不值得算:体积小可以豁免排序,变动频繁不行。

(这是对一份真实实现的可复核异议,不是 bug 报告:⑦ 在语义上先于 ⑧ 同样讲得通。而 pi 把 model_changeactive_tools_change 都记成会话条目(session/types.ts:28:39-41),说明它确实把”前缀会变”当一等事件在跟踪。)

②④ 夹在固定文案中间,代价是一次动态增删工具就把 ⑤⑦⑧⑨ 全部打成 miss。active_tools_change 之所以要落盘,正是为了事后算得出这笔钱。

三条纪律:

规则要能枚举,且最好能推导。 guideline 若是一整段自然语言,后追加的一条就可能悄悄改写前面所有条的优先级。pi 是一条条 addGuideline() 攒出来的(coding-agent/src/core/system-prompt.ts:104-124),其中相当一部分按当前工具集条件生成(三支取一,这里只抄 bash-only 那一支):

1
2
3
if ((hasBash || hasPowerShell) && !hasGrep && !hasFind && !hasLs) {
addGuideline("Use bash for file operations like ls, rg, find");
}

三者都没有 grep/find/ls 时才补一句”用 bash 代替”——规则是能力集合的函数,不是写死的文案。可枚举且可推导才可审计:能逐条列出”模型被要求遵守哪几条、每条为何存在”,才调试得动行为问题。附带的好处是确定性:同一份工具集永远拼出同一个字符串,前缀才不会自己漂。

能不进 prompt 的就不进(渐进披露)。 pi 的技能机制是标准样板,装进系统提示的只有三行(harness/system-prompt.ts:3-26):

1
<name>…</name>  <description>…</description>  <location>…绝对路径…</location>

正文一个字不进 prompt,配一句 “Read the full skill file when the task matches its description”,由模型自己 read 那个路径。本机 6 个技能实测:索引合计 6.4 KB,而 SKILL.md 正文合计 48 KB、单份最大 11 KB 出头——六个技能只用一个,省下的是五份正文,比例约 1:7.6。两本账在这里也分得很清楚:省下的那些是窗口(永远不进 PnP_n),而真被用到的那份,从第二次调用起就变成 cacheRead 按折价计。代价是多花一圈往返:按需加载把 token 换成了轮次。

装配的每个输入都是不可信数据。 项目上下文来自仓库文件,技能描述来自第三方包,都会原样进 prompt,所以入 prompt 前必须做结构转义。pi 对技能字段做 XML 五字符转义(harness/system-prompt.ts:27-34),挡的是”description 里塞一个 </available_skills> 就打断结构、后续内容被当成系统指令”这类注入。这不算强防御,但目录结构本身不该成为注入面。

② 取调用意图:工具契约与截断防御

resp.content.filter(c => c.type === "toolCall") 这一行,把前提二变成具体的解析工作。工具定义因此要同时管三件事:

参数结构。 模型输出的是文本,进宿主前必须过 schema。原则是:校验失败不要抛异常终止整圈,而是作为工具结果把失败原因还给模型——它有能力重发,这比崩掉便宜。

描述。 description 是模型唯一能看到的说明书,对行为的塑造力不小于实现本身。写”读文件”和写”输出会被截断到 2000 行 / 50KB,用完请用 offset 继续读”,模型是两套行为,后者才会分片读大文件。

这里有个需要机制保证的性质:描述与实际限制同源。pi 让描述从常量插值,而非常量与文档各写一份(harness/utils/truncate.ts:11-12 是两个值的唯一来源):

1
2
3
4
5
6
7
// truncate.ts:11-12   截断逻辑用的常量
export const DEFAULT_MAX_LINES = 2000;
export const DEFAULT_MAX_BYTES = 50 * 1024;

// read.ts:51 工具描述从同一批常量插值,而不是手敲数字
description: `… output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). …`
// ↑ 模型真正读到的是: "… truncated to 2000 lines or 50KB …"

截断逻辑与描述读同一个变量,改常量描述自动跟上。否则就是”实现已经改小、描述还说能读多少”,而它在用户侧的表现是模型莫名其妙地滥用工具,极难归因到一行文档字符串。

默认四件的实物。 三件事说完,把 pi 默认自带的全列出来当对照。createCodingTools 只有四件(coding-agent/src/core/tools/index.ts:195-202):

工具 参数 体积
read pathoffset?limit? 303 B
bash commandtimeout? 248 B
edit pathedits[]oldText + newText 326 B
write pathcontent 127 B

下面四条是模型实际看到的原文,逐字从 tag v0.84.4 的源码里取出;源中以 ${DEFAULT_MAX_LINES} 之类插值的部分,已按默认值展开成渲染后的形态。每条下的「中译」是本文加的,pi 并没有中文描述。

read (read.ts:51)

Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.

中译:读取文件内容。支持文本文件与图片(jpg、png、gif、webp、bmp),图片作为附件发送。文本文件的输出会被截断到 2000 行或 50KB,以先到者为准。大文件请使用 offset/limit。当你确实需要整个文件时,用 offset 一段接一段读完。

bash (bash.ts:57)

Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.

中译:在当前工作目录执行一条 bash 命令,返回 stdout 与 stderr。输出被截断到最后 2000 行或 50KB,以先到者为准;若发生截断,完整输出会另存到一个临时文件。可选提供一个以秒为单位的超时。

edit (edit.ts:98-99)

Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.

中译:用逐字文本替换编辑单个文件。每一个 edits[].oldText 都必须在原文件中匹配到唯一、且彼此不重叠的一段。若两处改动落在同一个块或相邻的行上,就把它们合并成一次编辑,而不是发出互相重叠的编辑。不要仅仅为了把相隔很远的改动串起来,就带上大段未改动的内容。

write (write.ts:23-24)

Write content to a file. Creates the file if it doesn’t exist, overwrites if it does. Automatically creates parent directories.

中译:向文件写入内容。文件不存在则创建,已存在则覆盖。父目录会自动创建。

grep / find / ls / powershell 也在仓里,但不在默认集;用户可用 defaultTools 设置覆盖(coding-agent/src/core/settings-manager.ts:128)。四件描述合计 1004 B ≈ 251 token,是固定前缀里的一笔常数开销——每圈都在,命中时按 preadp_{read} 付。

四个可抄的动作:

其一,每条描述都在预先回答”然后我该怎么办”。 read 说了截断,也给了续读入口;bash 说了截断,还说了全量落在临时文件里;write 说了自动建父目录,模型就不必先跑一次 mkdir。与只写”读文件””执行命令”的版本对比,差别不在信息量,而在模型撞上失败之后有没有下一步。

其二,截断方向是分别设计的,不是一刀切。 read 从头截,配 offset 往下续;bash 从尾截(truncated to last 2000 lines,因为报错和结论都在末尾),而且给全量留了文件出口。同一个截断器、两个方向、两个理由——这才是「压缩策略」那张表里”截断 = 无损”的真正前提:无损不是因为它没删东西,而是因为模型知道自己看到的是部分数据、从哪头切的、以及怎么拿到剩下的。

其三,能被确定性规则修的先修。 例子是 edit:它的 prepareArguments: prepareEditArguments(harness/tools/edit.ts:101) 就是 AgentTool 上那个兼容位(agent/src/types.ts:394)的实战:模型爱把 oldText / newText 平铺在顶层而不塞进 edits[],这种形状差异用确定性规则当场折叠,而不是丢给 schema 校验失败、再花一圈让它重发。所以前面”校验失败作为工具结果还给模型”要补一条限定:能被确定性规则修的,先修;修不了的才回给模型。

其四,默认集自己印证了 ① 那条”规则是能力集合的函数”。 四件里没有 grep / find / ls,于是 ① 引的那段条件 guideline 在默认配置下必然触发——本次会话的系统提示里就有一条 Use bash for file operations like ls, rg, find。规则与工具集是同一处代码算出来的,两者不会各说各话。

最后两处是与别的槽位接头:edit 把同一条约束写了两遍——工具级”must match a unique, non-overlapping region”(edit.ts:99)、参数级”matched against the original file, not incrementally”(edit.ts:33),措辞不同而指向同一件事,因为这正是模型最容易想错的语义,值得花双倍 token;而 editwrite 的 execute 外面都套着 withFileMutationQueue(harness/tools/edit.ts:105harness/tools/write.ts:28),read 不套——并发锁加在实现层,不加在描述里,那是 ④ 的活。

错误语义。 纪律是:宿主侧失败必须作为错误返回,不能伪装成正常内容——模型分不清”文件不存在”和”文件内容就是这句话”。pi 在 AgentTool 上的注释一句话(agent/src/types.ts:395):”Throw on failure instead of encoding errors in content“,错误到工具结果的转换由循环统一负责。

这一格还有一个必须专门处理的情形,来自”流式生成随时可能被掐断”:输出撞上 token 上限时,工具参数是边生成边解析的 JSON,抢救 parser 会尽力补全,产出一个”能解析、能过 schema、但内容静默不完整”的参数。它比缺失参数危险——缺失会被校验拦住,残缺会通过,然后真的执行半个操作。

处理办法是把不可信判定上移到整条消息:stopReason 表示被截断时,这一批所有调用一律不执行,并回一句可操作的重发提示。pi 就是这么做的(agent-loop.ts:231-232),措辞很客气,意思很硬:Re-issue the tool call with complete arguments

③ 判停:闸门、可验证性与共识退出

if calls 为空: break 是全文唯一一处”由概率模型决定的控制流”,前提一带来的问题也都集中在这里。四类必须分别处理的情形:

情形 表现 处理方式
重复 同一工具同一参数反复调,每次同样失败 幂等检测;把失败原因写得更可操作,让模型换路径
震荡 A 改了 B,B 又改回 A 给状态加不可逆约束,或要求”改回”必须给理由
超预算 每一圈都合理,累计在绕远路 步数 / token 硬闸门
假完成 模型宣布完成,实际未验证 循环内不可解,见下

前三类共用一个机制:把停止判定做成宿主可配置的闸门,而不是写死在循环里——不同任务的合理步数差三个数量级。合取式是一行的事:

继续下一圈    模型还要工具前提一¬预算耗尽闸门¬被叫停\text{继续下一圈} \iff \underbrace{\text{模型还要工具}}_{\text{前提一}} \wedge \underbrace{\neg\,\text{预算耗尽}}_{\text{闸门}} \wedge \underbrace{\neg\,\text{被叫停}}_{\text{人}}

pi 把中间项做成回调 shouldStopAfterTurn,每圈收尾问一次(agent-loop.ts:252)。闸门的位置比有无闸门更容易写错:必须在工具结果落进历史之后(也就是 ⑤ 之后)。早于落盘的早停会留下一段”要了工具没拿到结果”的断头历史,而且它会永久留在上下文里污染后续判断。

还有一类停滞根本不在模型侧:pi 的事件推送 emitawait 的(agent-loop.ts:26),UI 订阅者在回调里做同步 IO,循环就跟着卡。设计含义是 runtime 的推进速度对宿主回调的实现质量敏感——界面必须差分渲染,回调不得阻塞。

假完成这一类不能靠措辞解决,因为裁判和选手是同一个模型。可行的设计只有两种:引入独立的校验角色,或把”跑测试”做成一个必须被调用的工具。可操作的判据:

任何”完成”的断言,都应该能被一次工具调用反驳。

若模型说”改好了”而宿主无从验证(没有测试、没有 lint、没有可比对的期望输出),这一圈就是在用置信度替代证据。要提可靠性,先提可验证性。

同理,④ 里工具返回的”该停了”建议也不该是一票否决。它的语义应该是共识:

terminate    iterminatei\text{terminate} \iff \bigwedge_{i}\text{terminate}_i

全体一致才真的停(agent-loop.ts:580-582)。 单个工具的退出建议通常只是”这个任务在我这里没意义了”,它不该有权限否决其他工具的判断。代价是吞吐换确定性。

④ 执行:调度、副作用锁与权限边界

模型经常一次要多个工具。设计要分两层定:执行层可以并发,落盘层必须保序。

保序不是为了整齐,是为了因果唯一性:工具结果的顺序一旦随网络抖动而抖动,模型看到的 transcript 就不再是唯一历史,可复现性、前缀缓存命中、事后审计同时作废。标准解法是并发执行 + 按原始索引写回。pi 靠 Promise.all 保序,末尾再用一个顺序 for 逐条 emit 并 push(agent-loop.ts:538-546)——语言送的性质,很多人没意识到自己在依赖它。

调度粒度由最保守者决定:一批里只要有一个工具声明必须串行,整批退化为串行(agent-loop.ts:417-421)。这是正确性优先于吞吐的合理取舍,但设计时要清楚它的传染范围——给一个冷门工具加一句串行声明,能把整条并发链拖成单线程。

而并发边界真正该按什么划分?不按工具的自我声明,按副作用。两个工具读同一份数据,怎么并发都安全;两个工具往同一个文件写,声明得再漂亮也会互相踩。pi 的做法是给文件写操作按路径挂 promise 队列,key 用 canonicalPath 解析后的真实路径(harness/tools/file-mutation-queue.ts:20-26)——符号链接指向同一文件时自动进同一把锁。注意它有一层退路:canonicalPath 返回 not_foundnot_supported 时直接用未解析的绝对路径,所以经符号链接新建的文件、或环境不支持解析时,并不会合并到同一把锁上。

设计结论:并发控制的粒度最终落在副作用的标识上,而不是落在工具的自我声明上。

这一格推到底就是权限问题。前提二说手在宿主这边,那么把 bash 交给模型,等价于把 shell 交给模型。隔离性不可能来自 prompt,只能来自进程边界之外——prompt 里的规则是给模型的请求,不是给宿主的约束。要真边界,只有容器化、micro-VM,或把工具跑在受策略控制的沙箱里。

诚实的做法是像 pi 的 README 那样直接写明:没有内建权限系统,默认以启动者的权限运行。说明自己不提供什么,比含糊其辞更安全——后者会让使用者假设存在一道并不存在的防线。

⑤ 回灌与落盘:存储形态、压缩

messages := messages + [resp] + results 这一行是前提三的全部家当所在。先定存储形态,再谈省。三种取舍:

存法 能回头吗 崩溃安全 代价
内存数组 不能,进程死就没 简单,只够 demo
线性 log 不能,只能追加 分支/重试要复制整份
每个条目多两个指针字段

选树的理由是可恢复性需求:重试某一圈、换条路再试、比较两个方案、从上周三那个点继续——全是同一个操作,换个父节点往下写。用数组实现就得复制历史,而副本与原件的关系很快没人说得清。树形的代价只是每个条目多两行。pi 的会话条目基类(harness/session/types.ts:14-20)节选两行,其余还有 typeseqtimestamp

1
2
id: string;
parentId: string | null; // storage-assigned: the appending lane's leaf

底下是 append-only JSONL,不原地改只追加。崩溃安全是这套写法的赠品:最后一行写坏可以丢,前面永远有效。

由此得到一个决定后续所有设计的前提:会话是树的分支,而”当前上下文”只是从根到某个叶子的路径。 所以”历史上发生过什么”和”模型此刻看到什么”是两个正交问题——压缩、裁剪、投影只该影响后者读出的那条路径,不该改写存储本身。摘要只是树上的一个节点,真相仍在,别拿摘要当审计日志。

省上下文的手段按侵入性排开,前三条是”少灌进去”(属 ① 的装配问题),只有第四条是”灌进去之后往回收”:

手段 损什么 适用
截断 无损(模型知道自己在看部分数据) 单个工具返回值过大
按需加载 无损,多花轮次 静态知识的装配
外置 无损,检索质量成为新变量 超窗口的事实、跨会话的东西
压缩 有损 历史本身长了

压缩的触发是个朴素阈值:

compact    used>windowreserve\text{compact} \iff \text{used} > \text{window} - \text{reserve}

reserve 是给下一圈正常工作用的——不能等窗口满才压,那时连摘要请求本身都塞不下。真正要设计的是压完之后的结构。两种错误做法:全换成摘要(丢近端逐字细节,模型不记得自己上一圈改了什么)、只截头部(丢最初的需求描述)。可行的混合:

1
[系统提示] [远端历史的摘要] [近端原文尾巴] [当前轮]

pi 的 CompactionEntry 把两样东西并列成字段(harness/session/types.ts:44-51)——summary: stringretainedTail: AgentMessage[]——正是这个结构的直译;另外两个数各管一头,别混。摘要能写多长generateSummaryWithUsage 的输出预算定,取 Math.min(Math.floor(0.8 * reserveTokens), model.maxTokens)(compaction.ts:542),默认 reserveTokens = 16384 时约 13K token。尾巴留多少则是另一个参数:findCutPoint 从最新一条往回累计估算 token,够 keepRecentTokens(默认 20000)即停(compaction.ts:385-399),且落点会吸附到合法切点,而切点集合明确排除了 toolResult(compaction.ts:312-343)——不会把一次工具调用和它的返回结果拆开。两个默认值同表声明于 compaction.ts:157-162

还有一处必须一起设计的:压缩条目要附带一份”这段历史里读过哪些文件、改过哪些文件”的清单(compaction.ts:30-35)。摘要一定会漏掉它,而模型一旦忘记自己动过哪些手,下一圈就会重复读取甚至重复修改。这是压缩特有的失忆症状,必须结构化补回,不能指望摘要质量。

手写30行:把五个槽位压回能跑的形态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async function agentLoop(system: string, tools: Record<string, Tool>, messages: Msg[], budget: Budget) {
while (true) {
const resp = await LLM(system, messages, Object.values(tools)); // ① 重放全量,预算在这
messages.push(resp);

const calls = resp.content.filter((c) => c.type === "toolCall"); // ② 取意图
if (!calls.length) return resp; // ③ 不再要工具 = 完成

if (resp.stopReason === "length") { // ② 残缺比缺失危险
messages.push(...calls.map(rejectAll));
continue;
}
const settled = await Promise.all(calls.map(safeExecute)); // ④ 并发执行…
messages.push(...settled); // ⑤ …按原索引保序落盘

if (budget.exhausted()) return resp; // ③ 闸门,且在落盘之后
if (shouldCompact(messages, window, reserve)) messages = await compact(messages); // ⑤
}
}

三条前提的落点也还在原处:前提一在 if (!calls.length)budget.exhausted(),前提二在 toolssafeExecute,前提三在 messagesshouldCompactcompact。换 provider 不动这个函数,换工具不动这个函数,换 UI 只影响 safeExecute 与外部订阅。

总结

槽位列就是「最小形态」那七行伪码上的五个落点:① 调模型 → ② 取调用意图 → ③ 判停 → ④ 执行 → ⑤ 回灌与落盘。一行只标它主要咬在哪一格;跨格的按「决策点」归——落盘保序这个动作发生在 ⑤,但要不要等前面那批是在 ④ 定的,所以记在 ④;截断参数整批拒执行,防的是 ④ 的误动作,判据却在 ② 解析时就拿到了,所以记在 ②。

槽位 设计问题 通用解法 代价
窗口占用 控制工具返回值体积优先于精简提示词 模型看到的信息变薄,剩下的得自己分页续(默认截断值见 ② 那四条原文)
钱的口径 保住稳定前缀,少触发全量 miss 装配自由度被绑住:中途换模型、改前缀、压缩都要按全价重付一次;空闲过 TTL 照样失效
提示装配 分层拼装 + 规则可推导 + 索引化 + 易变垫后 + 转义 转义只挡结构,挡不住语义注入——pi 把它划到安全边界之外
工具接口 schema + 与实现同源的描述 + 错误即错误 描述得长期养:插值能同步数字,同步不了语义
不完整输出 判定上移到整条消息,整批拒执行 整批作废,多花一圈重发
终止条件 模型不再要工具 + 外置闸门(置于落盘后) 闸门设小会掐掉仍有价值的执行,设大等于没设
完成验证 独立角色或一次可反驳的工具调用 多一个角色或多一圈调用;校验方自己也会假阳性
退出建议 全体一致的共识而非一票否决 该停的时候停不下来,继续烧预算
调用调度 执行并发 + 落盘保序 + 最保守粒度 一句串行声明传染整批
副作用 按归一化路径加锁 每次写多两次路径解析,同路径的写被排队
权限边界 进程外隔离 沙箱得自己搭,而三条路各有明文限制(见下)
历史存储 append-only 树(parentId 多两个指针字段,基本免费
压缩 阈值 + 摘要与原文尾巴并存 + 文件清单 有损;且必须额外挂一份文件清单,否则模型不记得自己改过什么

④ 那格的”各有明文限制”不是修辞,pi 自己的文档写得很直白:Gondolin 要 Node ≥ 23.6.0 加 QEMU,且 /workspace 下的文件改动写穿透回宿主——它隔离的是执行环境,不是文件后果;用工具路由扩展时,其它自定义扩展工具仍跑在宿主上,除非它们自己也改道(containerization.md:17);纯 Docker 会让 provider API key 进容器;OpenShell 需要一个 gateway。安全文档更直接:没有内建沙箱,project trust 只是加载外部输入的闸门,而 “lack of a built-in sandbox, prompt injection from untrusted content” 属预期行为、不算漏洞(security.md:59)。

三条趋势:

  1. 从提示词到 runtime。 可靠性越来越不靠措辞,而靠结构:契约化的工具、外置的停止条件、可验证的完成判据。提示词退化成这个 runtime 的一个输入参数
  2. 上下文是一种内存管理。 分页在 GPU 侧,截断、按需加载、外置、压缩在 agent 侧,而 prompt cache 本身就是被跨请求复用的 KV cache——两边是同一题:有限窗口对上无界历史。那句 “Read the full skill file when the task matches”,本质就是按需 swap in
  3. 无状态模型 + 有状态宿主。 这条分界线一旦看清,全部设计负担就都落在宿主这一侧:记忆、约束、并发、验证、隔离,不会从天上掉下来

羽ばたいた数を数え空を舞う羽は,
翔于天际的白鸟 细数着自己振翅的次数,
小さな勇気でいつも眩しさだけ求め続けていた,
凭着这小小的勇气向着那耀眼的光芒 不断追寻,
歩き続ける事でしか届かないものがあるよ,
世上有着不继续走下去就无法传达的事情,
今も温かな手のぬくもりを探し続けている,
那双温暖的手的温度 现在我仍然在寻找,
いくつもの優しさを繋いでも辿り着けないから,
即使被无数的温柔包围 却仍无法抵达那个地方,
今も何度でもボクは夏の面影を,
现在我仍然将夏天的记忆,
振り返るよ,
不断重复。

—— アルカテイル (Alka Tale) · Summer Pockets

Reference