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

一次任务如何运行:模型、工具与执行循环

跟随一次文件检查,看清调用意图、程序执行、结果回填和结束边界。

一次任务如何运行:模型、工具与执行循环

Agent 并不是模型一边写回答、一边直接伸手读取电脑文件。一次工具任务通常分成几段:模型提出结构化请求,宿主处理请求,再把结果交回模型。

上一篇说清了模型、宿主和界面各自的分工,并给出了一次真实运行的 JSON 输出。这一篇接着往下走:一次"检查文件"的任务在内部究竟发生了什么,循环为什么会转第二圈,以及凭什么判断它已经停下来。

假设用户要求:"检查 src/greet.ts 中的命名函数声明,报告实际匹配到的名称。"下面的过程是教学轨迹,用来解释机制,不是对某个真实模型的运行记录。文中出现的 src/greet.ts 是教学文件,不表示已经对你的项目执行过检查。

先看信息与动作如何流动

图中模型节点会被多次访问:第一次选择检查,拿到结果后再决定是否继续。工具也可能被宿主拒绝,拒绝原因应成为明确结果,而非让模型误以为已经执行。

文件检查任务中的模型与执行循环文件检查任务中的模型与执行循环接收任务用户给出目标与范围模型选择下一步请求工具,或形成回答输出或停止仍需核查任务是否完成宿主检查请求参数、权限和预算工具实际执行读取允许的教学文件形成执行结果真实数据或明确失败调用允许拒绝回填停止文件检查任务中的模型与执行循环

这张图画出了关系,但没有画出顺序,这正是新手最容易误会的地方。图上"模型选择下一步"出现了两次,意思不是模型重头再来,而是第二次调用带着上一轮的结果重新决策:它已经知道 src/greet.ts 里有什么,因此要么直接生成报告,要么再读一个文件补齐证据。

"宿主检查请求"是一个分流点,不是必经的确认步骤。允许就走"工具实际执行",不允许就直接走"形成执行结果",两条路都会产出一条结果消息。拒绝也是一种结果,不是异常。

图上还缺一个关键问题:这些动作是谁按什么顺序调度的。图上看不出来,因为它是代码而不是数据。下一节先看清模型发出的请求长什么样,再回到源码找答案。

模型先提出一个意图

模型不能直接读取文件,它只能提出请求。用 Pi 的字段来写,一次读取请求是:

json
{
  "type": "toolCall",
  "id": "call_abc123",
  "name": "read",
  "arguments": { "path": "src/greet.ts" }
}

这里 read 是 Pi 真实的只读工具名,参数 path 也是它真实的参数名,不同于上一段图解里那个抽象的"检查文件"动作。它的参数由 schema 约束,路径超出允许范围时,校验就会失败。

各家的字段名不一样,读源码时需要对上号。同一件事,在 Pi 内部和各家接口上是几种写法:

宿主模型发出的调用块宿主写回结果时的标识
Pi
id
toolCallId
Anthropic Messages
id(tool_use 块)
tool_use_id
OpenAI Responses
id(另有 call_id)
call_id
OpenAI Chat Completions
id
tool_call_id

规律是:模型侧一律叫 id,宿主把结果写回去时,各家换了名字。Pi 内部始终用 toolCallId 承接,对外发往 Anthropic 时转成 tool_use_id,发往 OpenAI Responses 时转成 call_id。宿主内部只认自己那一个字段,转换发生在适配器里。

关键不在字段叫什么,而在工具名、参数和调用身份能被程序识别。不能仅靠一段自然语言"我将读取文件"来触发任意系统操作。

宿主在执行前会确认几件事:工具是否注册过、输入是否满足 schema、路径与权限是否允许。如果模型请求了不存在的工具,或者路径超出范围,合理结果是拒绝或错误,而不是替它猜一个危险动作执行。

真正读取发生在工具里

工具执行后,回填给模型的结果在 Pi 里是这样一条消息:

json
{
  "role": "toolResult",
  "toolCallId": "call_abc123",
  "toolName": "read",
  "content": [{ "type": "text", "text": "export function greet(name: string) {...}" }],
  "details": {},
  "isError": false,
  "timestamp": 1733234402000
}

toolCallId 把它和最初那次请求对上号,content 是模型能读到的内容,details 留给界面和程序,isError 说明这次执行是成功还是失败。

工具在返回结果之前,可以先组装一个自己的结果对象。Pi 里有六个字段,其中两个必填:

字段是否必填流向哪里作用
content
必填
发给模型
文字或图片内容,这是模型唯一能看到的部分
details
必填
给界面和程序
任意结构化数据,用于渲染和日志
structuredContent
可选
默认不给模型
匹配工具 outputSchema 的机器可读结果,供程序调用方使用
usage
可选
不进模型上下文
工具自身执行产生的用量
isError
可选
参与结果消息
报告失败而不必抛异常
terminate
可选
只影响宿主调度
提示这一批结束后可以停止

content 与 details 的分流是这里最值得记住的一点:同一个工具执行,产生了两份内容,一份给模型看,一份给程序用。structuredContent 默认不发给模型,想让模型用它,宿主得显式把它并进 content。

这份结果能证明到什么程度,取决于工具实现。如果工具只是正则扫描,它只能报告匹配到的命名声明,不能保证找出了所有函数,更不能据此宣布代码安全。

结果回到下一轮输入

模型通常需要在后续请求里看到工具结果,才能据此生成报告或选择下一步。如果工具已经执行,但结果没有进入下一轮上下文,模型仍可能继续猜测。

这也解释了为什么一次用户请求会触发多次模型调用。用户看见的是一条连续任务,内部可能经历多个"模型响应加工具结果"的回合。

不同框架对 turn、run 的命名不完全一致。与其记住名字,不如先约定一套工作定义:

  • tool call(工具调用):模型发出的一次结构化动作请求,带工具名和参数。模型只是提出请求,真正动手的是宿主。
  • turn(轮次):一次助手响应,加上这次响应产生的工具调用与工具结果。
  • run(一次运行):从 agent_start 到 agent_end 的那一段底层区间,一个 run 里可以包含多个 turn。

判断方法可以落到事件上:数事件流里 turn_start 与 turn_end 的配对关系,一对就是一个 turn。再往上一层,agent_end 只表示这一段底层运行结束了,重试、上下文压缩恢复、排队中的后续消息都还可能让工作继续;真正表示"这个会话层面没有自动工作要做了"的是 agent_settled。

文件缺失时,结果应明确缺失;模型可以询问路径或解释无法检查。失败并不总意味着整个进程崩溃,它也可以是下一轮决策所需的信息。

具体到模型这次请求里到底装了什么、角色怎么区分、哪些内容会被模型当成指令,模型如何接收任务那一篇接着展开。

Pi 源码里,这个循环长什么样

Pi 的执行循环在 packages/agent/src/agent-loop.ts 里,入口函数是 runLoop。它是一个双层 while 循环,外层管"本来要收工了,但有新消息排队",内层管"还有工具调用要处理,或者有新插入的消息":

ts
// Outer loop: continues when queued follow-up messages arrive after agent would stop
while (true) {
	let hasMoreToolCalls = true;

	// Inner loop: process tool calls and steering messages
	while (hasMoreToolCalls || pendingMessages.length > 0) {

内层循环每转一圈,做完四件事:

函数负责什么关键点
streamAssistantResponse
发出模型请求并流式收响应
这一圈从这里开始,模型拿到的是含上一轮结果的上下文
prepareToolCall
校验参数、跑 beforeToolCall 钩子
校验不过或被拦下,就不走执行,直接产生错误结果
executePreparedToolCall
真正调用工具
耗时被记录成 durationMs
createToolResultMessage
把结果组装成回填消息
这一圈在这里收口

按这个结构,一轮就是"模型的一次流式响应 + 由它产生的工具调用与结果"。工具结果被回填进上下文之后,内层循环的条件重新计算:有工具调用要处理就继续转,否则退出内层,外层再看看有没有新消息排着。

回填消息由 createToolResultMessage 生成,字段是可以逐个对照源码的:

ts
function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResultMessage {
	return {
		role: "toolResult",
		toolCallId: finalized.toolCall.id,
		toolName: finalized.toolCall.name,
		content: finalized.result.content ?? [],
		details: finalized.result.details,
		usage: finalized.result.usage,
		isError: finalized.isError,
		...(finalized.durationMs === undefined ? {} : { durationMs: finalized.durationMs }),
		timestamp: Date.now(),
	};
}

注意 durationMs 的写法:只有工具真的跑过才有这个字段,被拦下或者校验失败时它整个字段都不在。这也是后面判断"到底有没有执行"的一个信号。

宿主可以在执行前拦下请求

宿主要在工具动手之前拦住它,Pi 提供的是 beforeToolCall 钩子。它返回 { block: true, reason? } 时,工具不会执行:

ts
if (beforeResult?.block) {
	const result = createErrorToolResult(beforeResult.reason || "Tool execution was blocked");
	if (beforeResult.terminate === true) {
		result.terminate = true;
	}
	return {
		kind: "immediate",
		result,
		isError: true,
	};
}

这里有两个容易记错的地方。

第一,被拦下不是抛异常中断循环。工具没跑,但循环照常往下走,宿主写回一条 isError: true 的错误结果,默认文案是 "Tool execution was blocked",reason 给了就用 reason 替代它。模型看到的是一次失败的工具调用,可以据此改用别的办法。

第二,terminate: true 不是"立刻停止"。判定规则写在 shouldTerminateToolBatch 里:

ts
function shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean {
	return finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true);
}

同一批次里的每一个最终结果都 terminate,才真的提前停止。只要有一个不是,批次就不终止。这条规则对并行执行尤其重要:模型一次请求了三个工具,其中两个想终止、一个不终止,结果是三个都做完。

停止、完成和成功不是一件事

情况可以确定什么还需确认什么
模型停止输出
这次生成结束
是否还会调用工具或自动继续
工具执行结束
这次动作返回了结果
结果是否失败、是否回填
用户提出取消
停止意图已发出
实际执行者是否响应、已有副作用
系统输出报告
有了一份最终答复
是否满足真实完成标准

预算耗尽也可能迫使任务停止;无限重试不是自主能力的证明。模型说"完成了"只是公开输出,不能替代文件、测试或外部状态验证。

事件流能帮你区分上面几行。turn_end 会带上这一轮产生的 toolResults;agent_end 关闭的是一次底层运行,之后还可能有自动重试、溢出恢复或排队的后续任务;agent_settled 才表示 Pi 不会自动继续了。

运行事件可以帮助重建发生过的动作,但不能据此读取模型隐藏思考。后面的结果与可靠性会继续讨论,怎样从这条轨迹判断任务是否真正完成。

工作流与 Agent 的分界

Anthropic 在定义工作流和 Agent 时划了一条明确的线,原文是:

Workflows are systems where LLMs and tools are orchestrated through predefined code paths. Agents, on the other hand, are systems where LLMs dynamically direct their own processes and tool usage, maintaining control over how they accomplish tasks.

关键在后半句:Agent 的下一步由模型自己决定,工作流的下一步由预先写好的代码决定。本文讲的这个循环属于后者描述的那一类,因为"模型节点被访问两次"这件事,只有在模型能根据中间结果改变动作时才可能发生。

同文中,Anthropic 把常见的编排方式归为五种:Prompt chaining(提示链)、Routing(路由)、Parallelization(并行)、Orchestrator-workers(编排者与执行者)、Evaluator-optimizer(评估者与优化者)。这五种都是工作流形态,路径预先定好。

它们和 Agent 不是高下之分,而是选择问题。同一篇文章里的建议是:

When building applications with LLMs, we recommend finding the simplest solution possible, and only increasing complexity when needed.

如果你的任务能写成上面五种之一,写成工作流通常更省事也更好验证。只有当下一步确实取决于上一步的结果时,这个循环才是必要的。第八篇会回到这个选择上。

用运行事件验证一次循环

上一篇用 pi --mode json "Review this repository" 跑出过一次真实输出,其中三段事件是理解执行循环的关键。这里不重复贴同样的 JSON,只说清每一段究竟能支持什么结论。

事件它说明什么它不能说明什么
tool_execution_start
宿主开始处理这一次调用,带上模型给出的工具名与参数
不代表工具已经动手,也不代表这次调用被允许
tool_execution_update
工具报告了部分结果,长任务可能有多条
不代表这就是最终结果
tool_execution_end
这一次调用结束,result 是最终结果,isError 说明成败,durationMs 是工具真正花掉的时间
不代表结果已经进入模型上下文

三段都带同一个 toolCallId,这不是冗余。模型一次响应里可以同时请求多个工具,并行执行时它们的完成顺序并不固定,靠"上一条结果配上一条请求"会配错。toolCallId 是唯一的可靠关联键,它同时出现在这三段事件里,也出现在回填消息的 toolCallId 字段上,因此能一路串起"请求 → 执行 → 回填"。

真正能证明结果回填了的,是另一处证据:一条 role 为 toolResult 的消息。上一轮的输出里没有它,可以直接这样筛出来:

bash
pi --mode json "Review this repository" | jq -c 'select(.type=="message_end" and .message.role=="toolResult")'

把上面几件事合起来,一次执行是否真的发生过就有了可核对的清单:看到 tool_execution_start 说明请求进了宿主;看到 tool_execution_end 且 isError 为 false 说明工具真的跑了;看到 durationMs 说明它花了时间;看到 toolResult 消息说明结果进了模型能看到的地方。四者缺一,"模型说检查过了"都还只是说法。

小结

一次工具任务的骨架是:模型提出结构化请求,宿主校验并执行,结果带着 toolCallId 回填进上下文,模型据此决定下一步。循环转第二圈的必要条件,是上一步的结果真的回到了模型那里。

要判断它停没停,别看模型的措辞,看事件:数 turn_start 与 turn_end 的配对,看 tool_execution_end 里的 isError 和 durationMs,再确认有一条 toolResult 消息出现。

下一篇接着看这次请求里究竟装了什么,模型把不同来源的内容分别当成了什么。

资料依据