跳到主内容
创建时间:最后更新:作者:白川

请求准备与回合结束

分析 prepareRequest、工具结果和 finishTurn 的实际调用时机,说明结束条件为什么不会造成无限继续,以及从 shouldStopAfterTurn 迁移的写法。

请求准备与回合结束

写智能体最容易被写错的两件事:请求发出去之前发生什么,和这一轮结束之后发生什么。

这两个时机各有正式的接口:prepareRequest 和 finishTurn。它们不是可有可无的钩子,而是 Agent Core 里唯二能干预"这一轮怎么发"和"这一轮之后怎么走"的位置。

写错的后果都很具体:prepareRequest 用错会导致上下文被反复重建;finishTurn 用错会导致智能体停不下来,或者刚说完就断。

读完这篇你能画出一个回合的完整生命周期,标出每个钩子在哪一步;说清 prepareRequest 运行几次、能看到什么、不能做什么;也能证明结束条件不会造成无限继续,而错误写法会。

读它需要会用 TypeScript,并且已经跑通过带工具的 Agent。你还要清楚一个定义:一个 turn 是"一次助手回复 + 那次回复产生的所有工具调用与工具结果",一次运行可以包含多个 turn。

最终要跑通什么

  1. 能画出一次 provider 回合的完整生命周期,标出每个钩子在哪一步。
  2. 说清 prepareRequest 运行几次、能看到什么、不能做什么。
  3. 说清 finishTurn 在正常、错误、中止三种响应下分别怎么被调用。
  4. 能证明:结束条件不会造成无限继续,而错误写法会。

第 4 点是验收点。它必须能被跑出来的回合数验证。

一个回合的生命周期

官方 README 给出的顺序是:

text
# 一个回合的官方生命周期顺序,箭头方向就是执行顺序
selected input events
→ prepareRequest
→ provider response
→ tool results
→ finishTurn
→ turn_end
→ existing continuation scheduling or agent_end

对照 Agent Core 的事件循环实现,完整流程是这样的:

text
# 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:在每次请求之前

调用时机

源码里的调用点在事件循环的内层循环里,紧跟在消息追加之后:

typescript
// 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 里
返回值替换运行时状态
返回的上下文、模型、思考级别会替换掉当前值,并对后续回合继续生效

最后一条最容易被忽略。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 的设计意图:安装规范化的持久上下文。会话管理器是权威的,循环每回合都要重新投影一次,这样压缩、分支、外部修改都能在下一回合生效。

自己用它写一个简单的版本:

typescript
// 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 但返回值被丢弃:

typescript
// 源码里有 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 运行不包含之前已有的上下文消息。

三种返回值

返回值含义精确行为
undefined
保持正常调度
循环按默认规则决定要不要继续
{ action: "end" }
立即结束
紧接着 turn_end 之后结束运行,不轮询 steering 或 follow-up 队列,也不准备下一次请求
{ action: "continue" }
保证再发一次请求
保证会有一次下一次 provider 请求

"continue" 的语义需要展开一句,因为它不是"强制多发一次"。

在正常响应上,{ action: "continue" } 保证会有一次下一次 provider 请求。如果工具结果、steering 或 follow-up 已经安排了这次请求,它们满足了这个决策,不会额外发请求;否则循环用当前上下文再发一次。

源码对应这段:

typescript
// "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,并加上错误和中止的守卫:

typescript
// 迁移前: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,所以截断不会意外终止循环——模型需要机会重新发起完整的调用。

什么会让循环继续

这是本篇的核心判定表。写一个结束条件时,对着它检查一遍。

继续的原因触发条件优先级
工具结果
这一回合有工具调用,且没有整批 terminate
高
steering 消息
队列里有消息且本轮模式允许投递
高
follow-up 消息
内层循环结束时队列里有消息
中(在工具和 steering 之后)
finishTurn 返回 "continue"
正常响应,且上面几个都没安排请求
低(保底)
finishTurn 返回 "end"
正常响应
终止,不轮询队列

反过来,这些会让循环停:

停止的原因表现
模型自己不用工具
助手消息没有 toolCall,队列也空
finishTurn 返回 "end"
turn_end 之后紧跟 agent_end
整批 terminate: true
这一批之后不再发请求
响应是 error 或 aborted
硬退出,直接 agent_end

结束条件不会造成无限继续

**正确写法:**用一个有界的外部计数做终止条件。

typescript
// 计数放在 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。

typescript
// 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();
}

你应该看到:

text
[统计] 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",循环才硬退出)。这就是"无限继续"的具体样子——它不会立刻崩,它会一直转,转到某个外部条件把它打断为止。

谁负责什么

职责由谁承担说明
请求前的上下文装配
宿主
prepareRequest
工具执行与结果回填
运行时
结果进上下文,下一回合可见
什么时候停
宿主
finishTurn 返回 { action: "end" }
错误和中止的处理
运行时
硬退出,finishTurn 决策被忽略,工具不执行
提前终止一批工具
宿主
整批一致:所有结果都带 terminate: true 才生效
队列投递时机
运行时
prepareRequest 不参与
下一轮的开始准备
宿主
prepareNextTurn(可以追加消息,prepareRequest 不行)

你现在能做什么

  • 给 Agent 装一个 prepareRequest,每次请求前把权威上下文装回去,并验证它的返回值对后续回合继续生效。
  • 写一个 finishTurn,用有界的外部计数做终止条件,并验证 agent_end 之后不再有 turn_start。
  • 把 prepareRequest 换成无条件返回 { action: "continue" }(在 finishTurn 里),跑一次看它怎么一直转下去,直到外部条件打断它。
  • 在 prepareRequest 里读工具结果,基于"上次工具失败了"做一个换策略的判断。
  • 迁移一段旧的 shouldStopAfterTurn 逻辑到 finishTurn,并把错误和中止的守卫加上。

排障速查

现象检查点处理
循环停不下来
finishTurn
检查是不是无条件返回了 { action: "continue" }。终止条件要有界
finishTurn 不生效
响应类型
错误和中止是硬退出,决策忽略。谓词要自己挡掉这两种响应
finishTurn 在错误路径上白跑了
迁移
加 stopReason === "error" || "aborted" 守卫,避免副作用
改了 prepareRequest 但只在第一回合生效
返回值语义
返回的上下文/模型/思考级别对后续回合继续生效,不是单次改写
prepareRequest 里取不到队列消息
队列时机
它不轮询队列。队列投递由循环控制
想在 prepareRequest 里加消息
返回值类型
AgentRequestUpdate 去掉了 messages。用 prepareNextTurn
提前终止没生效
terminate
只有整批工具结果都带 terminate: true 才停
混合批次还是继续了
terminate
这是设计。一个工具不终止,整批就继续
工具没执行就结束了
响应类型
响应是 error 或 aborted 时,工具一个都不执行
turn_end 的 toolResults 是空的
事件字段
错误和中止路径下工具没跑,工具结果自然是空数组
回合数和请求次数对不上
计数位置
prepareRequest 每回合一次。若不等,检查是否有轮次在准备阶段就失败
停止之后还有排队消息
结束语义
{ action: "end" } 不轮询队列,排队消息留在队列里

参考资料