一个 agent 到底在做什么?剥掉所有外壳,剩下的就是一个 while 循环:模型说点什么,可能还要调工具,拿到工具结果,再让模型说点什么,直到没有理由继续说下去。

话是这么说,真把它写对是另一回事。我在读 pi 的 packages/agent 时,反复卡在几个问题上:一份对话历史要怎样才能被完整重放?什么时候该请求模型、什么时候不该?用户在我跑工具的时候插一句话,这句话应该出现在哪里?最后,到底是谁决定停下来?

这篇是我读完 agent-loop.ts 之后的整理,先说 pi 怎么做的,再说我理解的一个通用 agent loop 应该长什么样。

先把三个词分清楚

读代码之前最容易被绕晕的是 run、turn、request 这三个词混着用。

「run」是一次 prompt() 或 continue() 调用的全过程,从 agent_start 到 agent_end。用户说一句话,agent 开始干活,干到它自己停下,这是一个 run。

「turn」是 run 内部的一轮,从 turn_start 到 turn_end。一个 turn 恰好对应一次模型请求,加上这次请求引发的工具执行。这里我一开始理解反了,以为一个 turn 里可能有多次请求,实际上不是——内层循环每转一圈只调一次 streamAssistantResponse,接着就发 turn_end,下一次请求必然先经过 turn_start。请求次数和 turn 数是相等的。

所以一层套一层的结构是:一个 run 包含若干个 turn,一个 turn 包含一条 assistant 消息、零到若干条工具结果消息、以及可能插入的 system 或 user 消息。

在这里插入图片描述

代码上的入口也很直白。packages/agent/src/agent-loop.ts 的门面只有几个,其余全是内部函数:

// agent-loop.ts:47  —— 带新 prompt 启动
export function agentLoop(prompts, context, config, signal, streamFn): EventStream<AgentEvent, AgentMessage[]>;

// agent-loop.ts:80  —— 从现有上下文续跑(重试场景)
export function agentLoopContinue(context, config, signal, streamFn): EventStream<AgentEvent, AgentMessage[]>;

// agent-loop.ts:111 —— 两者最终都调它
export async function runAgentLoop(prompts, context, config, emit, signal, streamFn): Promise<AgentMessage[]>;

agentLoop 做的事很薄:建一个 EventStream,把 runAgentLoop 的 emit 接到 stream.push 上,跑完再把结果 end 掉。真正的工作都在 runLoop 里。

顺便说一句,不带工具调用的 turn 也是完整的 turn,形式是 turn_start、一条 assistant 消息、turn_end { toolResults: [] }。turn 的最小构成就是一条 assistant 消息,工具只是可选部分。

进循环之前:历史里不存状态,只存变化

pi 的设计里有一个决定味道很重的选择:工具定义不放在请求的顶层字段上,而是写进 transcript 里。

具体做法是,每次准备发消息之前,先重放一遍 transcript 中所有 system 消息上的 toolsAdded 和 toolsRemoved,算出「模型目前以为有哪些工具可用」;再和 context.tools(运行时可执行的工具)比较,把差量写成一条新的 system 消息追加进去。

在这里插入图片描述

// agent-loop.ts:342
function declareToolChanges(context: AgentContext, pendingMessages: AgentMessage[]): AgentMessage[] {
    // 1. 重放 transcript,得到「模型以为的工具集」
    const changes = getToolStateChanges(
        getCurrentTools([...context.messages, ...baseline]),
        (context.tools ?? []).map(toToolDeclaration),
    );
    // 2. 无差异 → 原样返回,不改写任何消息
    if (unchanged) return pendingMessages;
    // 3. 有差异 → 插一条 system 消息,只带差量
    return [...pendingMessages.slice(0, index), update, ...pendingMessages.slice(index)];
}

getCurrentTools 本身就是一个纯重放函数,它按顺序应用每条 system 消息上的增删,最后剩下的就是当前工具集:

// packages/ai/src/utils/transcript.ts:58
export function getCurrentTools(messages: TranscriptMessages): Tool[] {
    const tools = new Map<string, Tool>();
    for (const message of messages) {
        if (!isSystemMessage(message)) continue;
        for (const tool of message.toolsRemoved ?? []) tools.delete(tool.name);
        for (const tool of message.toolsAdded ?? []) tools.set(tool.name, tool);
    }
    return [...tools.values()];
}

为什么非要重放,不能直接存「当前工具集」

直觉上,请求体里放一个顶层 tools: [...] 字段就完事了,何必绕一圈。但要回答下面这几个问题,顶层字段是没用的:

  • 第 3 轮请求时,模型看到的是哪几个工具?
  • 三小时前恢复这个会话,当时装了哪些工具?
  • 这一轮没调用 bash,是因为它当时不可用,还是模型没想到?

答案都躺在 transcript 外面,跟着某次请求一起消失了。历史变成不可验证:你知道现在能做什么,但算不出当时模型以为能做什么。

把工具集存成增量,这些问题就都能从 transcript 里算出来:

① system    toolsAdded: [read, write, bash]
② user      "帮我看看 config.json"
③ assistant toolCall(read, ...)
④ toolResult ...
⑤ system    toolsRemoved: [{name:"bash"}], toolsAdded: [{name:"grep"}]
⑥ user      "再搜一下 TODO"
重放到应用的动作得到的工具集
④① 加入 read/write/bash{read, write, bash}
⑥① 加三个,⑤ 删 bash、加 grep{read, write, grep}

任意截断位置都能算出那一刻的真实状态,这就是「可重放」。

为什么只能追加,不能改历史

既然要用增量,为什么不能干脆回头改 ① 那条消息,把 bash 换成 grep?

因为一条消息一旦随请求发出去,它就不再是本地草稿,而是已经发生的事实。改动它会同时引爆三个后果。

prompt cache 的前缀全部作废。 provider 的 prompt cache 按精确的字节前缀命中:这次请求的开头如果和上次逐字节相同,就直接复用之前的计算结果。pi 把这件事记在用量里:

// packages/ai/src/types.ts:462
cacheRead: number;    // 命中缓存复用的 token
cacheWrite: number;   // 新写入缓存的 token

改动 ① 之后,从第 0 个字节起就和上次对不上,provider 只能整段重算——cacheRead 归零,cacheWrite 涨到全量。几万 token 的上下文,缓存命中价通常只有未命中的十分之一到二十分之一,改一条早期消息等于把整段历史重新付费一次。追加在末尾的 toolsRemoved / toolsAdded 则保住了前面所有字节,前缀依然有效。

(顺带一提,coding-agent 里有个 cache-warmer,会在缓存快过期时发一个 maxTokens: 1 的请求把前缀重新焐热,它赖以工作的前提正是「前缀没变」。)

会话无法原样持久化。 coding-agent 的会话文件是 JSONL,一条一行,追加写:

type: "session" | "message" | "model_change" | "compaction" | "custom" | ...

这个格式天生只支持追加。允许改历史,持久化就得多一套「定位并覆盖第 N 行」的机制,还要处理并发和崩溃时的半写状态。更麻烦的是语义塌了:恢复会话时本地重放出的 transcript,必须和当初每次请求实际发出的内容一致;历史若可改,「日志里记的」和「当时真发的」就是两份东西,恢复出来的会话和原始会话不再是同一个。

重放结果会自相矛盾。 这条最隐蔽。③ 那条 assistant 消息调用了 read。如果把 ① 改成只声明 [grep] 再从头重放:

重放到 ③ 之前,可用工具 = {grep}
③ 的 assistant 消息却说:「我要调用 read」

read 在那一刻根本不可用,这段历史自相矛盾了。后果不只是日志难看——事后审计会得出错误结论「模型调用了未声明的工具,这是 bug」;依赖「调用时工具一定可用」的代码在重放时可能崩。缓存失效只是钱的问题,这个是正确性的问题。

分界线:只改还没发出去的东西

declareToolChanges 明明会改写 system 消息,看起来违反了上面的规则:

if (pending) {
    // 把它的工具字段替换成差值,保留 content / sections
    return baseline.map((m, i) => (i === systemIndex ? withToolChanges(pending, changes) : m));
}

关键在于这里的 pending 是本批次里还没落盘的那条——可能是 prepareNextTurn 刚产出、或者用户刚排队进来的 system 消息。它还没随任何请求发出去,改它不违反任何约束。

状态能不能改
已随请求发出不能,只能追加新消息描述变化
本批次待发能,还没成为事实

所以整个机制的收益可以一句话概括:历史自包含,截断到任意位置都能还原当时的工具集;会话原样持久化;prompt cache 前缀稳定;重放永远不会自相矛盾。代价只是多一层重放逻辑,换来的是历史从「不可验证」变成「可验证」。

runAgentLoop 在真正进循环之前做了四件事:补上工具声明的差量、建一个记录本次新增消息的数组、发出 agent_start 和首轮的 turn_start、把这次传入的 prompt 消息逐条广播出去。

// agent-loop.ts:111
export async function runAgentLoop(prompts, context, config, emit, signal, streamFn) {
    const initialMessages = declareToolChanges(context, prompts);        // ① 补工具声明
    const newMessages: AgentMessage[] = [...initialMessages];            // ② 本次新增
    const currentContext: AgentContext = {
        ...context,
        messages: [...context.messages, ...initialMessages],
    };

    await emit({ type: "agent_start" });                                 // ③
    await emit({ type: "turn_start" });                                  //    首轮的 turn_start
    for (const message of initialMessages) {                             // ④ 广播 prompt
        await emit({ type: "message_start", message });
        await emit({ type: "message_end", message });
    }

    await runLoop(currentContext, newMessages, config, signal, emit, streamFn ?? getDefaultStreamFn());
    return newMessages;
}

这里注意首轮的 turn_start 是在循环外面发的,循环内部只负责第二轮及以后。这一点在看代码时容易漏掉,以为 turn_start 会在循环体里均匀发出。

一圈之内发生了什么

内层循环的条件是「还有工具调用」或者「还有待处理的消息」:

// agent-loop.ts:188
while (true) {                                    // 外层:follow-up
    let hasMoreToolCalls = true;                  // 「这次外层迭代还欠一次请求」
    while (hasMoreToolCalls || pendingMessages.length > 0) {   // 内层:一个 turn
        ...
    }
}

这个条件经常被读成「还有活要干」,但更准确的读法是:这一轮有没有客观证据证明还得再请求一次模型。证据只有两种,模型要了工具(结果必须回传),或者用户插了话(必须送出去)。除此之外没有第三种理由能让循环再转一圈。

在这里插入图片描述

进入循环后,一轮依次经过这些动作。

第一步是回合间准备,但只有非首轮才做。上一轮结束时会留下一个 lastCompletedTurn,里面是上一轮的 assistant 消息、工具结果、当时的上下文、以及目前为止新增的全部消息。这里有个我一开始理解错的地方——pi 并不复制这份快照。这四个字段装的是引用,context 和 newMessages 在后续轮次里还会继续被修改。所以钩子拿到的不是冻结的历史,而是「截至目前的最新状态」。

// agent-loop.ts:193
if (lastCompletedTurn) {
    const nextTurnSnapshot = await config.prepareNextTurn?.(lastCompletedTurn);
    if (nextTurnSnapshot) {
        currentContext = nextTurnSnapshot.context ?? currentContext;   // 替换语义
        config = { ...config, model: nextTurnSnapshot.model ?? config.model, ... };
    }
    // 准备可能耗时(比如压缩),补一次 steering 轮询;只在上次取空时补
    if (pendingMessages.length === 0) {
        pendingMessages = (await config.getSteeringMessages?.()) || [];
    }
    await emit({ type: "turn_start" });
}

补轮询的原因是 prepareNextTurn 可能跑很久,比如做上下文压缩,用户在这期间会继续输入。但只在上一次取空时才补,否则「一次取一条」的模式会在一轮里塞进两条消息。

第二步是把上一轮准备的消息和队列里的消息合并,过一遍工具声明的差量检查,然后逐条落盘——每条都真发一次 message_start 和 message_end,既进上下文,也进新增消息数组。

// agent-loop.ts:220
for (const message of declareToolChanges(currentContext, [...preparedMessages, ...pendingMessages])) {
    await emit({ type: "message_start", message });
    await emit({ type: "message_end", message });
    currentContext.messages.push(message);
    newMessages.push(message);
}
pendingMessages = [];

第三步是 prepareRequest,它在每次请求之前都会跑,包括第一轮。它能替换上下文、模型和思考等级,但类型上不允许追加消息:

// types.ts:180
export type AgentRequestUpdate = Omit<AgentLoopTurnUpdate, "messages">;

这个限制是有意为之的:请求前的钩子如果还能改写历史,那重放出来的 transcript 就和实际发出去的对不上了。「可以在第一轮就生效」是它和 prepareNextTurn 最实际的区别,因为后者首轮不跑。

第四步是真正请求模型。内部链路是 transformContext(可选,纯视图,改完即弃)、convertToLlm(把 AgentMessage 转成 provider 能懂的 Message)、normalizeContext、动态解析 API key,最后交给 stream function:

// agent-loop.ts:390
async function streamAssistantResponse(context, config, signal, emit, streamFunction) {
    let messages = context.messages;
    if (config.transformContext) messages = await config.transformContext(messages, signal);  // 改完即弃
    const llmMessages = await config.convertToLlm(messages);
    const llmContext = normalizeContext({ messages: llmMessages });
    const resolvedApiKey = (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;

    const response = await streamFunction(config.model, llmContext, { ...config, apiKey: resolvedApiKey, signal });

    for await (const event of response) {
        switch (event.type) {
            case "start":
                context.messages.push(event.partial);
                await emit({ type: "message_start", message: { ...event.partial } });
                break;
            case "text_delta":
            case "toolcall_delta":
                // 原地替换最后一个元素,不是追加
                context.messages[context.messages.length - 1] = event.partial;
                await emit({ type: "message_update", assistantMessageEvent: event, message: { ...event.partial } });
                break;
            case "done":
            case "error":
                // 用最终消息替换,发 message_end
                ...
        }
    }
}

流式过程中的部分消息是原地替换数组最后一个元素,不是追加,所以下游看到的是同一条消息在不断长大。

第五步是一道岔路。如果响应以 error 或 aborted 结束,循环立刻封口退出——但仍然会调用一次 finishTurn,只是返回值被丢弃:

// agent-loop.ts:254
if (message.stopReason === "error" || message.stopReason === "aborted") {
    await config.finishTurn?.(lastCompletedTurn, signal);   // 调用,但忽略返回值
    await emit({ type: "turn_end", message, toolResults: [] });
    await emit({ type: "agent_end", messages: newMessages });
    return;                                                  // 硬退出
}

第六步是执行工具,但前提是消息内容里真的有工具调用块:

// agent-loop.ts:268
const toolCalls = message.content.filter((c) => c.type === "toolCall");
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
    const executedToolBatch = message.stopReason === "length"
        ? await failToolCallsFromTruncatedMessage(toolCalls, emit)   // 截断 → 整批报错
        : await executeToolCalls(currentContext, message, config, signal, emit);
    hasMoreToolCalls = !executedToolBatch.terminate;                 // 全票制
    for (const result of executedToolBatch.messages) {
        currentContext.messages.push(result);
        newMessages.push(result);
    }
}

这里又要强调一次:判断依据是内容,不是 stopReason。provider 对 stopReason 的语义实现各不相同,而「消息里有没有工具调用块」是确定的事实。模型回了 stop 但内容里带着工具调用,照样执行;回了 toolUse 但内容里没有,循环照停。

工具执行有两条保护。一是 stopReason 是 length 时,整批工具调用全部判为失败——输出被 token 上限截断时,流式工具参数是用「尽力抢救」的解析器收尾的,可能解析成功、甚至通过 schema 校验,但内容不完整,执行它的后果比不执行严重得多。二是终止标记是全票制:

// agent-loop.ts:700
function shouldTerminateToolBatch(finalizedCalls) {
    return finalizedCalls.length > 0 && finalizedCalls.every((f) => f.result.terminate === true);
}

单次工具调用本身的五步管线也值得看一眼,因为错误分支都在这里:

在这里插入图片描述

并行模式下还有两种顺序并存的现象,preflight 串行、执行并发:

在这里插入图片描述

第七步是收尾。构造这一轮的快照,调 finishTurn,发 turn_end,然后根据决策处理:

// agent-loop.ts:289
lastCompletedTurn = { message, toolResults, context: currentContext, newMessages };
const decision = await config.finishTurn?.(lastCompletedTurn, signal);
await emit({ type: "turn_end", message, toolResults });

if (decision?.action === "end") {
    await emit({ type: "agent_end", messages: newMessages });
    return;
}

explicitContinuation = decision?.action === "continue";
pendingMessages = (await config.getSteeringMessages?.()) || [];
if (hasMoreToolCalls || pendingMessages.length > 0) {
    explicitContinuation = false;      // 已有续跑理由,不再额外发请求
}

finishTurn 被安排在 turn_end 之前、队列轮询之前,这两个「之前」都有意义:前者让它看到的是最终态,后者让它能抢在队列被消费之前叫停,用户刚打的字会留在队列里不丢。

用户插话的两个通道

pi 给了两个队列,steering 和 follow-up,区别不在「发不发」,而在什么时候被取出来。

steering 的语义是「这一轮照常做完,下一轮带上我」。它的轮询点其实有三个,不止 turn_end——循环启动之前会查一次(用户可能在 prompt 之前就排了消息),prepareNextTurn 之后会补查一次,然后才是每个 turn_end 之后。

在这里插入图片描述

// agent-loop.ts:186  —— ① 循环启动前
let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];

// agent-loop.ts:213  —— ② prepareNextTurn 之后,只在上次取空时补
if (pendingMessages.length === 0) {
    pendingMessages = (await config.getSteeringMessages?.()) || [];
}

// agent-loop.ts:304  —— ③ 每个 turn_end 之后,主要路径
pendingMessages = (await config.getSteeringMessages?.()) || [];

follow-up 的语义是「等它彻底停下来再说」。它只有一处在内层循环退出之后才被轮询:

// agent-loop.ts:311
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
if (followUpMessages.length > 0) {
    explicitContinuation = false;
    pendingMessages = followUpMessages;
    continue;                    // 回外层顶部,重新进内层
}

这个时序差异不是为了省事。follow-up 有一个硬性前提:agent 真的停下来了。如果把它的轮询塞进内层循环的末尾,那么只要循环没退出,它就一直被查——用户在 agent 还在跑工具时排一条 follow-up,消息会被提前注入,契约就破了。

队列本身的实现很简单,取多少条由模式决定:

// agent.ts:149
class PendingMessageQueue {
    peek(): AgentMessage[] {
        if (this.mode === "all") return this.messages.slice();
        const first = this.messages[0];
        return first ? [first] : [];      // one-at-a-time(默认)
    }
}

两个队列都不是「立即发送」。这个架构里没有「立即打断」的概念,abort() 才是打断,它中止整个 run,队列里的消息也不会投递。

谁决定停下来

这是我在读代码前最想当然的部分。我原以为 stopReason 会给出停止信号,实际上并不完全是。

stopReason 在循环里只被读两处:error 和 aborted 触发硬退出,length 不退出但让整批工具判失败。stop 和 toolUse 对循环没有任何区别——它们是给日志和 UI 看的,不参与调度。

顺带说一下这个字段的来源,它其实是混合产物。stop、length、toolUse 是 provider 的原始值经过映射:

// packages/ai/src/api/anthropic-messages.ts:1671
function mapStopReason(reason: BetaStopReason | string) {
    switch (reason) {
        case "end_turn":    return { stopReason: "stop" };
        case "max_tokens":  return { stopReason: "length" };
        case "tool_use":    return { stopReason: "toolUse" };
        case "refusal":     return { stopReason: "error", errorMessage: ... };
        case "pause_turn":  return { stopReason: "stop" };
        default:            throw new Error(`Unhandled stop reason: ${reason}`);
    }
}
// packages/ai/src/api/openai-completions.ts:1560
function mapStopReason(reason) {
    if (reason === null) return { stopReason: "stop" };
    switch (reason) {
        case "stop": case "end":   return { stopReason: "stop" };
        case "length":             return { stopReason: "length" };
        case "tool_calls":         return { stopReason: "toolUse" };
        case "content_filter":     return { stopReason: "error", errorMessage: "Provider finish_reason: content_filter" };
        default:                   return { stopReason: "error", errorMessage: `Provider finish_reason: ${reason}` };
    }
}

error 一半来自 provider 的明确报告(拒答、内容过滤),一半是代码兜底(未知的结束原因)。pending 是代码写死的初始占位。而 aborted 只可能由本地的中止信号产生,provider 根本不知道发生了什么:

// packages/ai/src/api/anthropic-messages.ts:919
output.stopReason = options?.signal?.aborted ? "aborted" : "error";

所以这个字段最准确的解释是「这条 assistant 消息是怎么结束的」,而不是「provider 说了什么」。

真正的终止来源有三条。

最常见的是结构条件自然耗尽:没有更多工具调用、没有待处理消息、follow-up 也是空的,循环就退出了。这是模型用「有没有发工具调用块」投票、工具用「是否全票终止」投票的结果。

然后是硬失败,stopReason 为 error 或 aborted,直接返回,不可挽回。

再是 finishTurn 返回「结束」,在 turn_end 之后立刻退出,队列不清空。

还有一件容易被忽略的事:这个循环没有任何轮数上限。搜不到 maxTurns、maxSteps 之类的计数,只要模型每轮都发工具调用,它可以永远跑下去。这是刻意的,loop 不知道「任务完成」的定义,那是应用层的判断,硬编码一个数字反而会误导。实际约束来自别处:上下文窗口满了会触发压缩,用户会按中止,provider 会报错。唯一有硬上限的是 packages/durable,但那是另一份独立实现。

控制流分成两张图来看。第一张是外层:什么时候决定再跑一轮,什么时候真正退出。

在这里插入图片描述

第二张是内层的一轮,从 turn_start 之前的准备一路走到 turn_end 之后的决策,其中包括 error / aborted 的硬退出和 finishTurn 的 end:

在这里插入图片描述

两张图合起来就是全部退出点:硬失败、finishTurn 的 end、结构耗尽。

finishTurn:一个预留的插嘴口

前面反复提到这个钩子,值得单独说。

一轮做完、turn_end 还没发的时候,loop 把整轮交给它,问一句「接下来呢」。回答有三种:不表态,按默认规则走;要求再请求一次;立即结束。

在这里插入图片描述

签名本身就是这段设计的说明:

// types.ts:147
export type AgentTurnDecision = { action: "continue" } | { action: "end" };

// types.ts:155
export type FinishTurn = (
    turn: AgentTurnContext,
    signal?: AbortSignal,
) => AgentTurnDecision | void | Promise<AgentTurnDecision | undefined> | Promise<void>;

入参是刚封口的那一轮,newMessages 仍是引用,会随循环继续增长:

// types.ts:135
export interface AgentTurnContext {
    message: AssistantMessage;          // 本轮 assistant 消息
    toolResults: ToolResultMessage[];   // 本轮工具结果(按源顺序)
    context: AgentContext;              // 已追加完的完整上下文
    newMessages: AgentMessage[];        // 本次 loop 调用的增量
}

这里有个不直观的等价关系:返回「继续」时,如果本轮的工具结果或者刚取到的 steering 已经能产生那次请求,它就满足于此,不会额外多发一次;只有什么都没产生时,才会补一次上下文不变的请求——同一份 transcript 再发给模型一遍。测试里专门断言了这种情况会让 provider 被调用两次:

// packages/agent/test/agent-loop.test.ts:1273
it("makes exactly one context-only request when no natural request satisfies continuation", async () => {
    let providerCalls = 0;
    ...
    await stream.result();
    expect(providerCalls).toBe(2);     // 同一份上下文,请求了两次
});

所以「继续」的精确含义不是「额外再发一次」,而是「这一轮之后必须再有一次请求」。

为什么需要这个钩子?因为 loop 自己的规矩只有两条:有工具就继续,有人插话就继续。规矩有两个盲区。一边是该停却让它继续——调了某个特殊工具、已经跑了四十轮明显在打转、上下文快满了。另一边是该继续却让它收工——模型只回了句「好的我来看看」、没按要求格式回、开了自检模式。这两种判断都需要应用层才有的知识。

所以 pi 的选择是不判断,只留一个空位。不装这个钩子,循环就老老实实按结构走;装了,就在每轮结束时问一句。这个取舍是对的——通用循环不该知道什么叫「任务完成」。

用的时候有三个坑。错误和中止的轮次也会调用它,但返回值被丢弃,所以有副作用的钩子会在失败轮次也跑一遍。无条件返回「继续」是死循环,每轮都继续、每轮都发请求、每轮又调钩子。返回「结束」不会清空队列,用户刚打的字还在,下次 continue() 或 prompt() 才处理。

真正安装它的只有 coding-agent,而且做的是链式包装,end 优先级最高:

// packages/coding-agent/src/core/agent-session.ts:903
private _installAgentBoundaryHooks(): void {
    const previousFinishTurn = this.agent.finishTurn;
    this.agent.finishTurn = async (turn, signal) => {
        const extensionContinue = await this._dispatchTurnEndBoundary(turn.message, turn.toolResults);
        const previousDecision = await previousFinishTurn?.(turn, signal);
        if (previousDecision?.action === "end") return previousDecision;     // 谁要停就停
        if (extensionContinue || previousDecision?.action === "continue") return { action: "continue" };
        return undefined;
    };
}

完整时序

把前面所有东西按时间排一遍,一次带工具调用的 prompt("读 config.json"):

在这里插入图片描述

三个退出点都发 agent_end:错误/中止、finishTurn 返回 end、自然结束。所以下游只要看到 agent_end,无论哪条路径都是完整的事件序列。

我理解的通用 agent loop

读完 pi 之后,我意识到自己一直把 agent loop 想成"一个循环"。实际上它更像一个把「模型的一次回答」反复变成「下一次输入」的装置——它自己不产生任何智能,只负责让模型的每句话都有机会引发下一步动作。

想清楚这件事,再去看它的结构就顺了:真正需要"判断"的部分全都不在循环里,循环只负责排序。把这些判断之外的东西归一下类,大致是三块——核心、接缝、外部。

这三块的分界,可以这样记:核心是不能没有的,接缝是别人能改的,外部是换掉也无所谓的。 换句话问自己三个问题——去掉它还算不算一个 agent loop?别的程序拿去用要不要动它?换一个模型服务商要不要改它?

在这里插入图片描述

核心:六件事

剥掉所有可选项,一个 agent loop 必须做的只有六件事:

  1. 持有一份对话状态,并且只增不减
  2. 把状态变成一次请求(连同裁剪、投影)
  3. 发请求、消费流,得到一条完整的 assistant 消息
  4. 消息里有工具调用时,执行并把结果追加回状态
  5. 判断是否继续
  6. 发出事件

这六件事没有一件需要知道"任务是否完成"、“该用哪个模型”、“上下文该不该压缩”。它们全部是关于顺序和边界的,不关于判断。

其中三件值得展开。

状态要能被重放。 所有变化都写成增量消息追加在末尾——工具声明的增减、prompt 的修改、sections 的替换。任何时候想知道「当时是什么状态」,从某个点开始重放一遍就有答案。这样做换来四个性质:能截断到任意位置重放、能原样持久化、prompt cache 前缀稳定、事后可审计。反过来,只要允许第二份"状态副本"存在(顶层 tools 字段、会话里的 currentTools 快照),就一定会出现副本和历史对不上的时刻。

与之相对的是可执行工具集(state.tools)。它是代码现场持有的对象,带 execute 函数,不进历史;它和 transcript 的关系是「比较的右操作数」——declareToolChanges 拿它和重放结果比对,算出差量。

循环的最小单元是一次请求,不是一次用户输入。 因为工具调用天然把一次输入拆成多轮请求,每轮都要独立地做上下文准备、参数校验、结果记账。以请求为粒度,「这一轮到底发生了什么」才有清晰边界。

判断"要不要继续"只能认客观证据:模型要了工具,或者用户排了消息,再叠加 error / aborted 的硬退出。它不猜"模型是不是还想继续",也不靠 stopReason 暗示。缺了这条纪律,循环会退化成启发式判断的堆叠。

关于第六条:它是核心的对外那一面

前五条是 loop 的内部职责,去掉任何一条它就算不出正确结果。第六条是对外契约:去掉它,循环照样正确,只是外面看不见。

如果 loop 只返回最终的消息数组,过程就是完全的黑箱——模型逐字输出看不到,工具跑到一半的进度看不到,五个工具并发时谁先完成也不知道。一个交互式 agent 就退化成批处理任务。

所以 pi 在关键节点上都调一次 emit,agent-loop.ts 里有二十六处 await emit(...),事件一共九种:

// agent-loop.ts:528
| { type: "agent_start" }
| { type: "agent_end"; messages }                    // 携带本次新增的全部消息
| { type: "turn_start" }
| { type: "turn_end"; message; toolResults }
| { type: "message_start"; message }
| { type: "message_update"; message; assistantMessageEvent }   // 流式增量
| { type: "message_end"; message }
| { type: "tool_execution_start"; toolCallId; toolName; args }
| { type: "tool_execution_update"; toolCallId; partialResult }
| { type: "tool_execution_end"; toolCallId; result; isError; durationMs }

三个细节值得记。emit 是 await 的(AgentEventSink 返回 Promise<void> | void),所以监听者的速度会拖慢整轮流程——Agent 反过来利用这点,把 message_end 当成屏障,保证 beforeToolCall 看到的是已包含这条 assistant 消息的上下文。事件里发出去的是 { ...partialMessage } 这样的拷贝而不是引用,因为 partial 对象后续会被原地替换,监听者拿到的必须是当时那一刻的样子。agent_end 虽然是最后一条,但不代表已经空闲——要等所有监听器 settle 完。

一个纯服务端、跑完才交付的批处理任务,这一条可以整条省掉。所以它列在核心清单里,但性质上更像"核心的对外那一面"。

接缝:必须留口子,但不替调用方判断

循环必须有一处把应用消息转成 provider 消息,必须有一个出错和用户中止时的退出路径——这些是核心的一部分。但"以可替换的钩子形式提供"是 pi 的选择。

钩子位置让出的是什么
convertToLlm请求前应用消息类型 → provider 消息类型的转换
transformContext请求前,纯视图上下文裁剪、注入(改完即弃)
prepareRequest每次请求前用哪份上下文、哪个模型、哪个思考等级
prepareNextTurn回合之间压缩、prompt 刷新(允许追加消息)
finishTurn回合末尾任务完成与否、要不要再来一轮
steering / follow-up多个轮询点用户插话的分级投递

具体挂哪些钩子、挂在哪,由调用它的程序决定。transformContext 的裁剪规则、finishTurn 的终止策略、steering 的分级投递,全都是上层应用的知识,不是循环的知识。循环需要的是接缝,不是这些具体的钩子。

但接缝的位置有讲究,不能随便挪:

  • prepareRequest 不能追加消息,是因为请求钩子若还能改写历史,重放就和实际发出对不上了
  • prepareNextTurn 能追加消息,是因为它在 turn_start 之前,新消息有明确落点
  • finishTurn 卡在「全部结果落地之后、调度权交回之前」,是唯一能同时看到整轮结果和决定去留的时刻

每多一个钩子,就等于承认「这件事 loop 不该管」。

外部:跟 loop 无关的那部分

协议序列化、持久化、缓存放热、UI 渲染——这些换一层实现也不影响 loop 本身。

拿工具声明举例:内部始终维护增量形式,到了 transport 层才决定怎么落成线上格式。支持中途声明的走就地声明(changes),只支持增量的做部分折叠(additions),只认顶层字段的全部折叠(none)。

证据很直接:工具声明用顶层字段还是就地声明、消息怎么摆成 HTTP body,这些代码在 packages/ai/src/api/*.ts 里,而 agent-loop.ts 从头到尾没碰过 params.tools。把适配算进 loop 的职责,"loop 应该做什么"就没有边界了。

内部表示要尽量完备,向下适配放在最后一刻。如果反过来、一开始就按最弱的 provider 存成顶层字段,重放能力会被直接丢掉——而这跟 provider 无关,是内部表示的问题。

三者怎么分工

核心定义了这个东西;接缝决定别人能不能按自己的需要改它;外部决定换一家模型服务商要动多少代码。

只有第一项是不可少的。接缝可以一个都不留(那样它只是个勉强能跑的原型),外部也可以完全不存在(本地 demo 用不上持久化和缓存)。

回头看我原来的说法,把"钩子"和"适配"并列成一个四层的框架,是不准确的——钩子是对外的接口,适配是内部的序列化策略,性质不同。真正被让到循环之外的,只有判断这一件事:核心提供判断的依据,接缝接收判断的结果,外部把结果按协议发出去。


最后

开头那四个问题,现在都能回答了。

重放靠的是把变化写进历史,而不是在旁边维护一份状态副本;该不该请求模型、什么时候请求,靠的是本轮有没有产出必须回传的东西,不靠猜;用户的话按意图分成两个队列,各有各的投递时机;停止则分三条路——结构耗尽、硬失败,以及应用层自己的判断。

但如果只记住这四条,学到的还是 pi 的答案。更有用的或许是它回答这些问题的方式:每遇到一个需要判断的地方,它都不把判断写进循环,而是留一个位置等外面来填。

这也是为什么在 agent-loop.ts 里几乎找不到"该不该"、"要不要"这类词。它只负责让事情按正确顺序发生:消息按顺序落盘、工具结果按顺序回填、下一轮按顺序开始。至于这一轮算不算成功、任务算不算做完、上下文该不该压缩——那是别人的事。

写一个自己的 agent loop 时,我会拿两件事去对:循环里有没有 if (toolName === "finish") break、if (rounds > 10) break 这类业务判断——它们本该是 finishTurn 那样的出口;状态的每一次改动有没有留下历史——直接改内存对象而不留痕,重启就恢复不了,出问题也查不出来。

一句话总结:好的 agent loop 不聪明,它只是从不替别人做决定。

Logo

更多推荐