请求准备与回合结束
分析 prepareRequest、工具结果和 finishTurn 的实际调用时机,说明结束条件为什么不会造成无限继续,以及从 shouldStopAfterTurn 迁移的写法。
请求准备与回合结束
写智能体最容易被写错的两件事:请求发出去之前发生什么,和这一轮结束之后发生什么。
这两个时机各有正式的接口:prepareRequest 和 finishTurn。它们不是可有可无的钩子,而是 Agent Core 里唯二能干预"这一轮怎么发"和"这一轮之后怎么走"的位置。
写错的后果都很具体:prepareRequest 用错会导致上下文被反复重建;finishTurn 用错会导致智能体停不下来,或者刚说完就断。
读完这篇你能画出一个回合的完整生命周期,标出每个钩子在哪一步;说清 prepareRequest 运行几次、能看到什么、不能做什么;也能证明结束条件不会造成无限继续,而错误写法会。
读它需要会用 TypeScript,并且已经跑通过带工具的 Agent。你还要清楚一个定义:一个 turn 是"一次助手回复 + 那次回复产生的所有工具调用与工具结果",一次运行可以包含多个 turn。
最终要跑通什么
- 能画出一次 provider 回合的完整生命周期,标出每个钩子在哪一步。
- 说清
prepareRequest运行几次、能看到什么、不能做什么。 - 说清
finishTurn在正常、错误、中止三种响应下分别怎么被调用。 - 能证明:结束条件不会造成无限继续,而错误写法会。
第 4 点是验收点。它必须能被跑出来的回合数验证。
一个回合的生命周期
官方 README 给出的顺序是:
# 一个回合的官方生命周期顺序,箭头方向就是执行顺序
selected input events
→ prepareRequest
→ provider response
→ tool results
→ finishTurn
→ turn_end
→ existing continuation scheduling or agent_end对照 Agent Core 的事件循环实现,完整流程是这样的:
# Agent Core 事件循环的双层结构
┌─ 外层循环:负责 follow-up 消息和显式续跑
│
│ ┌─ 内层循环:一次 provider 回合
│ │
│ │ ① 若上一轮已完成:
│ │ prepareNextTurn() ← 可替换上下文/模型/思考级别,追加消息
│ │ (steering 轮询,仅当没有待处理消息时)
│ │ emit turn_start
│ │
│ │ ② 追加待处理消息,逐条 emit message_start / message_end
│ │
│ │ ③ prepareRequest() ← 本回合唯一的请求准备点
│ │
│ │ ④ provider 响应 → 助手消息
│ │ 若 stopReason 是 error / aborted:
│ │ finishTurn()(决策被忽略)
│ │ emit turn_end → emit agent_end → 结束
│ │
│ │ ⑤ 执行工具调用 → 工具结果消息
│ │
│ │ ⑥ finishTurn() ← 正常响应的决策点
│ │
│ │ ⑦ emit turn_end
│ │
│ │ ⑧ 决策分流:
│ │ action === "end" → emit agent_end → 结束
│ │ action === "continue" → 记下显式续跑
│ │ undefined → 保持默认调度
│ │
│ │ ⑨ steering 轮询
│ └─ 内层循环结束
│
│ ⑩ follow-up 消息轮询
│ ⑪ 若有显式续跑标记 → 再来一个回合
└─ 外层循环结束 → emit agent_end这张图有三个地方值得停下来看。
第一,prepareRequest 在第 ③ 步,每回合一次。 它在待处理消息已经追加并发出事件之后、provider 请求发出之前运行。
第二,finishTurn 在第 ⑥ 步,但错误和中止响应走的是另一条路径。 第 ④ 步里,error 和 aborted 会直接跳到 finishTurn(决策被忽略)然后 turn_end → agent_end。工具在这种情况下一个都不会执行。
第三,第 ⑧ 步和第 ⑪ 步是循环继续的两个来源。 第 ⑧ 步的 "continue" 决策和工具结果、steering 消息会互相覆盖,第 ⑪ 步是最后一道保险。
prepareRequest:在每次请求之前
调用时机
源码里的调用点在事件循环的内层循环里,紧跟在消息追加之后:
// Agent Core 事件循环里的实际调用点:每回合一次,在消息追加之后、provider 请求发出之前
const requestUpdate = await config.prepareRequest?.(
{
context: currentContext,
model: config.model,
thinkingLevel: config.reasoning ?? "off",
},
signal,
);
if (requestUpdate) {
// 返回的上下文替换当前值,并对这次运行剩下的所有回合继续生效
currentContext = requestUpdate.context ?? currentContext;
config = {
...config,
// 不返回 model 就保留原值(?? 的语义),显式返回更清楚
model: requestUpdate.model ?? config.model,
// "off" 表示不要推理,循环把 reasoning 设成 undefined 而不是字符串 "off"
reasoning:
requestUpdate.thinkingLevel === undefined
? config.reasoning
: requestUpdate.thinkingLevel === "off"
? undefined
: requestUpdate.thinkingLevel,
};
}由此得出几条确定的事实:
最后一条最容易被忽略。prepareRequest 不是"这一次请求的临时改写",它改的是这次运行剩下的全部回合。
它能改什么
PrepareRequestContext 给你三样东西:context(当前上下文,含消息和工具)、model(当前模型)、thinkingLevel(当前思考级别,"off" 时循环把 reasoning 设为 undefined)。
AgentRequestUpdate 让你还回同样三样:context(替换接下来用的上下文)、model、thinkingLevel。
它不能追加消息。 AgentRequestUpdate 是 Omit<AgentLoopTurnUpdate, "messages">——显式去掉了 messages 字段。要追加消息,用 prepareNextTurn。
它的典型用途
Coding Agent 自己的用法就是最好的例子:把会话的权威投影装进每一次请求。它的 prepareRequest 调 sessionManager.buildSessionProjection() 得到当前投影,用投影的消息替换上下文里的消息,再把 agent.state.tools 复制进 context.tools。
这就是 prepareRequest 的设计意图:安装规范化的持久上下文。会话管理器是权威的,循环每回合都要重新投影一次,这样压缩、分支、外部修改都能在下一回合生效。
自己用它写一个简单的版本:
// prepare.ts
import type { Agent } from "@earendil-works/pi-agent-core";
// 把「权威上下文」的装载挂到每一次请求前
export function installCanonicalContext(agent: Agent, load: () => Promise<unknown[]>) {
agent.prepareRequest = async ({ context, model, thinkingLevel }) => {
// 每回合重新装载权威上下文:压缩、分支、外部修改都能在下一回合生效
const messages = await load();
// 返回的 context 替换接下来用的上下文,并对后续回合继续生效
return { context: { ...context, messages }, model, thinkingLevel };
};
}注意这里返回了 model 和 thinkingLevel——不返回它们,循环会保留原值(?? config.model 的语义),但显式返回更清楚。
它不轮询队列
官方 README 特别强调:prepareRequest 不轮询队列。它运行期间排队的 steering 消息,要等到下一次正常的 steering 轮询才被取走。
源码印证了这点:getSteeringMessages() 的调用点全都在 prepareRequest 之外——进入循环前的一次初始轮询、内层循环开头的 lastCompletedTurn 分支里,以及 finishTurn 之后。
所以在 prepareRequest 里写"顺便取一下队列"是错的:你拿不到队列,而且会破坏循环对队列投递时机的控制。
工具结果如何进入下一轮
工具执行完,工具结果消息被追加进上下文,然后内层循环回到顶部,进入下一回合。上下文里多出来的是一条 role: "toolResult" 的消息,带 toolCallId、toolName、content、isError。
下一回合的 prepareRequest 看到的上下文已经包含这些工具结果。这给了你一个介入点:如果你要基于工具结果做判断(比如"工具失败了就换个策略"),prepareRequest 里能读到它们。
finishTurn:在回合定稿之后
调用时机
源码里有两个调用点。错误和中止路径上,调用了 finishTurn 但返回值被丢弃:
// 源码里有 finishTurn 的两个调用点,返回值的待遇完全不同
// 路径一:错误和中止(Agent Core 事件循环里)
if (message.stopReason === "error" || message.stopReason === "aborted") {
lastCompletedTurn = { message, toolResults: [], context: currentContext, newMessages };
await config.finishTurn?.(lastCompletedTurn, signal); // ← 返回值被丢弃
// 错误路径上工具一个都没跑,所以 toolResults 是空数组
await emit({ type: "turn_end", message, toolResults: [] });
await emit({ type: "agent_end", messages: newMessages });
return;
}
// 路径二:正常响应(Agent Core 事件循环里)
lastCompletedTurn = { message, toolResults, context: currentContext, newMessages };
// 正常响应才拿到决策,但决策作用于 turn_end 之后的调度
const decision = await config.finishTurn?.(lastCompletedTurn, signal);
await emit({ type: "turn_end", message, toolResults });三条结论:正常、错误、中止三种响应都会调用 finishTurn;错误和中止是硬退出,它们的决策被忽略,工具也不会执行;正常响应的决策在 turn_end 之后生效——finishTurn 返回后才发 turn_end,但决策作用于 turn_end 之后的调度。
注意错误路径里 toolResults 是空数组,turn_end 事件也带空数组——因为工具确实一个都没跑。
拿到的上下文
AgentTurnContext 有四个字段:message(完成这一回合的助手消息)、toolResults(这一回合产生的工具结果消息)、context(助手消息和工具结果都已追加之后的当前上下文)、newMessages(这次循环调用结束时会返回的消息)。
newMessages 的语义有讲究:prompt 运行包含最初的 prompt 消息,continuation 运行不包含之前已有的上下文消息。
三种返回值
"continue" 的语义需要展开一句,因为它不是"强制多发一次"。
在正常响应上,
{ action: "continue" }保证会有一次下一次 provider 请求。如果工具结果、steering 或 follow-up 已经安排了这次请求,它们满足了这个决策,不会额外发请求;否则循环用当前上下文再发一次。
源码对应这段:
// "continue" 只是保底:工具结果或 steering 已经安排了请求时,显式续跑作废
explicitContinuation = decision?.action === "continue";
pendingMessages = (await config.getSteeringMessages?.()) || [];
if (hasMoreToolCalls || pendingMessages.length > 0) {
explicitContinuation = false; // 已经被别的东西安排了,显式续跑作废
}也就是说:"continue" 是一个保底,不是覆盖。工具结果和 steering 的优先级更高。
迁移:shouldStopAfterTurn 已被移除
官方 CHANGELOG 记录了这个破坏性变更:AgentOptions.shouldStopAfterTurn 和 AgentLoopConfig.shouldStopAfterTurn 都被移除了。
迁移写法是把谓词搬进 finishTurn,并加上错误和中止的守卫:
// 迁移前:shouldStopAfterTurn 已在 Agent Core 里被移除
agent.shouldStopAfterTurn = async (turn, signal) => {
return shouldStop(turn, signal);
};
// 迁移后:谓词搬进 finishTurn,并补上错误和中止的守卫
agent.finishTurn = async (turn, signal) => {
// shouldStopAfterTurn 以前只在正常响应上运行
if (turn.message.stopReason === "error" || turn.message.stopReason === "aborted") return;
// 谓词返回 true 才结束;否则交回 undefined 保持默认调度
return (await shouldStop(turn, signal)) ? { action: "end" } : undefined;
};那个守卫不是形式主义。旧的 shouldStopAfterTurn 只在正常响应上被调用,而 finishTurn 三种响应都会跑。如果你的谓词有副作用(计数、写日志、发请求)或者假设了响应一定成功,不加守卫就会在错误路径上意外执行。
不加守卫行为上"碰巧"是对的——错误响应上的 finishTurn 决策本来就被忽略,{ action: "end" } 不会生效。真正的问题是副作用已经执行了。
terminate:整批一致语义
工具可以返回 terminate: true,含义是"提示这一批之后不要再自动发起下一次模型请求"。三处可以设它:工具的 execute() 返回值、beforeToolCall 拦截的结果、afterToolCall 的返回值。
判定规则只有一条——shouldTerminateToolBatch() 要求这一批非空,并且所有已定稿的工具结果都设了 terminate: true。混合批次照常继续。
这个"整批一致"的设计有实际理由:一个批次里的工具是模型一次决策的结果。如果其中一个不终止,说明模型还想让这个批次跑完并看到结果——此时单独停掉整批会让模型拿不到另一半的结果。
还有两点:terminate 只是运行时提示,发出去的 toolResult 消息仍然是标准的 LLM 工具结果,不带这个标记;长度截断导致的失败工具调用(failToolCallsFromTruncatedMessage)返回的 terminate 是 false,所以截断不会意外终止循环——模型需要机会重新发起完整的调用。
什么会让循环继续
这是本篇的核心判定表。写一个结束条件时,对着它检查一遍。
反过来,这些会让循环停:
结束条件不会造成无限继续
**正确写法:**用一个有界的外部计数做终止条件。
// 计数放在 Agent 外部:finishTurn 每次调用都要能读到上一轮的累计值
let turnCount = 0;
agent.finishTurn = async ({ message }) => {
// 错误和中止是硬退出,决策会被忽略,这里先挡掉
if (message.stopReason === "error" || message.stopReason === "aborted") return;
turnCount += 1;
// 跑满两轮就收工:条件有界,所以不会造成无限继续
return turnCount >= 2 ? { action: "end" } : undefined;
};预期结果:agent_end 之后不再有 turn_start;这次运行总共只发生了你设定的那几轮。
这个写法安全的原因是条件有界——turnCount 单调增长,达到阈值就返回 "end"。
错误写法一:无条件返回 continue。 finishTurn 在下一次请求之后还会再跑。无条件返回 "continue" 意味着每一轮都要求再来一次请求,永远不会停。
错误写法二:条件依赖模型配合。 比如"模型说了 DONE 就停"。这本身不是错,但模型不说就一直转,直到别的东西打断它。用外部可观测的计数做终止条件,比用模型输出里的关键词可靠。
错误写法三:把停止逻辑放在 prepareRequest 里。 prepareRequest 的返回值里没有"结束"这个选项——它只能替换上下文、模型和思考级别。要停就用 finishTurn。
一段可运行的验证代码
这段代码把上面的判定表变成可观察的输出。它不花钱,用的是 fauxProvider。
// turns.ts
import { Agent } from "@earendil-works/pi-agent-core";
import {
createModels,
fauxAssistantMessage,
fauxProvider,
fauxText,
fauxToolCall,
Type,
} from "@earendil-works/pi-ai";
const faux = fauxProvider();
const models = createModels();
models.setProvider(faux.provider);
const model = faux.getModel();
// 模型连续三轮都要求调工具
faux.setResponses([
fauxAssistantMessage([fauxToolCall("noop", {})], { stopReason: "toolUse" }),
]);
faux.appendResponses([
fauxAssistantMessage([fauxToolCall("noop", {})], { stopReason: "toolUse" }),
]);
faux.appendResponses([
fauxAssistantMessage([fauxToolCall("noop", {})], { stopReason: "toolUse" }),
]);
faux.appendResponses([fauxAssistantMessage([fauxText("好了")])]);
const agent = new Agent({
initialState: {
systemPrompt: "你是测试助手。",
model,
tools: [
{
name: "noop",
label: "空操作",
description: "什么都不做",
parameters: Type.Object({}),
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
},
],
},
streamFn: models.streamSimple.bind(models),
});
let requestCount = 0;
let turnCount = 0;
agent.prepareRequest = async ({ context, model: m, thinkingLevel }) => {
requestCount += 1;
// prepareRequest 不轮询队列,这里也不该去碰队列
return { context, model: m, thinkingLevel };
};
agent.finishTurn = async ({ message }) => {
// 错误和中止是硬退出,决策会被忽略
if (message.stopReason === "error" || message.stopReason === "aborted") return;
turnCount += 1;
// 有界的终止条件:跑满两轮就收工
return turnCount >= 2 ? { action: "end" } : undefined;
};
const turnStarts: number[] = [];
const unsubscribe = agent.subscribe((event) => {
if (event.type === "turn_start") turnStarts.push(turnStarts.length);
});
try {
await agent.prompt("开始");
console.error("[统计] provider 请求次数 =", requestCount);
console.error("[统计] finishTurn 调用次数 =", turnCount);
console.error("[统计] turn_start 次数 =", turnStarts.length);
console.error("[统计] 消息数 =", agent.state.messages.length);
} finally {
unsubscribe();
agent.abort();
await agent.waitForIdle();
}你应该看到:
[统计] provider 请求次数 = 2
[统计] finishTurn 调用次数 = 2
[统计] turn_start 次数 = 2
[统计] 消息数 = 5四个数字说明:
prepareRequest每回合一次(请求次数 = 回合数)。finishTurn在第 2 回合返回"end",所以只有 2 回合。- 第 2 回合的
turn_end之后直接agent_end,没有第 3 个turn_start。 - 消息数是 5:用户消息、助手消息(含 toolCall)、工具结果消息、助手消息(含 toolCall)、工具结果消息——最后一轮的工具执行完了才轮到
finishTurn决定停止,所以工具结果消息已经进了 transcript。
把 finishTurn 改成无条件返回 { action: "continue" },再跑一次。 回合数会一直涨下去,直到 faux provider 的响应队列耗尽(队列空时它会发出一个 error 事件、终态消息的 stopReason 是 "error",循环才硬退出)。这就是"无限继续"的具体样子——它不会立刻崩,它会一直转,转到某个外部条件把它打断为止。
谁负责什么
你现在能做什么
- 给 Agent 装一个
prepareRequest,每次请求前把权威上下文装回去,并验证它的返回值对后续回合继续生效。 - 写一个
finishTurn,用有界的外部计数做终止条件,并验证agent_end之后不再有turn_start。 - 把
prepareRequest换成无条件返回{ action: "continue" }(在finishTurn里),跑一次看它怎么一直转下去,直到外部条件打断它。 - 在
prepareRequest里读工具结果,基于"上次工具失败了"做一个换策略的判断。 - 迁移一段旧的
shouldStopAfterTurn逻辑到finishTurn,并把错误和中止的守卫加上。