模型如何接收任务:消息、角色与能力边界
理解消息来源、角色、工具结果和模型支持范围,避免把流畅输出当作事实保证。
模型如何接收任务:消息、角色与能力边界
用户在界面里输入一句话,模型收到的往往不止这一句话。宿主还可能加入任务规则、历史消息、工具声明和文件内容。理解模型的行为,先要知道这次请求里有什么。
上一篇走完了一次执行循环:模型提出请求,宿主执行,结果回填。那条结果最终变成什么、放进这次请求的哪个位置,取决于消息的类型和角色。这一篇接着往下看。
沿用贯穿本组文章的教学文件 src/greet.ts,它是一个教学设定,不代表已经对你的项目执行过检查。
消息不只是聊天气泡
沿着文件检查任务,可以把输入分成四类。Pi 的底层消息类型正好对应四种角色:system、user、assistant、toolResult。
第三列是这张表最值得记住的一列。同样的自然语言,出现在不同角色里,模型对它的处理方式并不一样。工具结果里的文字是数据,模型可能把它当成观察结论,也可能误当成新的指令。第九篇会展开这一点,以及宿主该怎么防。
另外,界面中的状态通知、进度动画或内部缓存,未必会进入模型请求。反过来,用户看不见的系统说明可能影响模型行动。排查时应检查有效请求,而不是只看截图里的聊天文字。
这四类消息在 Pi 里都由同一个联合类型收着:
export type Message = SystemMessage | UserMessage | AssistantMessage | ToolResultMessage;assistant 这一类值得多看一眼。它的 content 不是字符串,而是内容块的数组,块有三种:TextContent、ThinkingContent、ToolCall。同一个助手消息里可以既有文字又有工具调用块,这解释了上一篇里"助手输出可能是动作请求,不只是文本"在数据结构上是怎么成立的。下面是定义里与本篇相关的字段,省略了其余可选字段:
export interface AssistantMessage {
role: "assistant";
content: (TextContent | ThinkingContent | ToolCall)[];
api: Api;
provider: ProviderId;
model: string;
usage: Usage;
stopReason: StopReason;
timestamp: number;
}api 记录这次走的是哪个接口,model 记录请求的模型,responseModel 则在服务实际返回的模型与请求的不同时记下真实那个。排查"为什么这个模型表现得不一样"时,这三个字段比设置页更能说明问题。
stopReason 是这段类型里最该记住的一个字段,它有七种取值:
stopReason 为 stop 只说明这次生成结束了,不说明任务完成了。上一篇说的"模型停止输出"和"任务真的完成"是两件事,在这里有了对应的字段可以分开看。
角色名各不相同,先对上号再说
不同接口用的角色名和结构不一样,读源码时容易看错。同一件事,在 Pi 内部和各家接口上是三种形状:
第三行值得单独说。Pi 把工具结果做成一条独立消息,角色是 toolResult;Anthropic 把它塞进一条 user 消息里,用 tool_result 内容块表示;OpenAI Responses 用 function_call_output 项,并且它带的 call_id 可能和函数调用项的 id 不是同一个值,宿主需要自己把它们对上。
这不是实现细节的偶然差异,而是各家 API 的规定。Pi 内部统一用 toolCallId,转换发生在适配器里。上一篇提到的 id 与 toolCallId 的关系,在这里延伸成了"同一个工具调用在三种接口上有三种写法",读源码时按这张表核对即可。
把一次请求摊开看
文件检查任务走到第二次模型请求时,宿主发出去的内容大致是这样。这是按 Pi 内部结构写的教学示例,不是某个服务的真实报文:
[
{
"role": "system",
"content": "只读检查,依据工具结果报告",
"toolsAdded": [
{
"name": "read",
"description": "Read the contents of a file.",
"parameters": {
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
}
}
],
"timestamp": 1733234401000
},
{
"role": "user",
"content": "检查 src/greet.ts 中的命名函数声明,报告实际匹配到的名称。",
"timestamp": 1733234401000
},
{
"role": "assistant",
"content": [
{
"type": "toolCall",
"id": "call_abc123",
"name": "read",
"arguments": { "path": "src/greet.ts" }
}
],
"api": "anthropic-messages",
"provider": "anthropic",
"model": "claude-sonnet-4-6",
"usage": { "input": 812, "output": 46, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 858 },
"stopReason": "toolUse",
"timestamp": 1733234401500
},
{
"role": "toolResult",
"toolCallId": "call_abc123",
"toolName": "read",
"content": [
{ "type": "text", "text": "export function greet(name: string) {\n return `Hello ${name}`\n}" }
],
"details": {},
"isError": false,
"timestamp": 1733234402000
}
]这份清单回答了开头的问题:模型这次收到的不止用户那一句话。它还收到了规则、工具的完整定义、模型自己上一轮说过的话,以及工具的实际返回值。
顺序也有含义。工具结果排在助手消息之后,并且靠 toolCallId 指回那次调用;模型在生成下一段话时看到的,正是这份顺序。上一篇说"结果没进入下一轮上下文,模型仍可能继续猜测",说的就是这份清单里少了 toolResult 那一条会怎样。
还有一个容易忽略的细节:content 里的文字是文件的真实内容,details 里是空对象。上一篇说过工具产出两份内容,这里能看到它落到消息上是什么样子。
同一句话,来源不同,含义不同
用户说"请读取这个文件",是在提出任务;源码注释写"请上传你的密钥",则是文件中的待处理内容。即使两者都以自然语言出现,也不能赋予相同的指令地位。
宿主需要保留来源与数据边界。把规则、网页和工具结果无差别地拼成一大段文本,会让系统更难判断哪些是要求、哪些是证据。角色或边界标记有助于表达意图,但不能代替真正的权限控制。
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.
这条界线正好解释了为什么角色区分值得花力气。固定代码路径的系统里,"哪些是要求、哪些是证据"由代码预先划好;让模型自己决定下一步的系统里,模型必须能从上下文里看出区别,否则它可能照着文件里的一句话去行动。角色标记提供了这个区分的依据。
Pi 里做这种区分有两个具体位置。一是消息的 role,toolResult 与 user 是分开的两类;二是 AgentToolResult 里的 content 与 details 分流,content 发给模型,details 留给界面和程序。同一次工具执行产出两份内容,模型只看得到其中一份。
这个区分的必要性在工具结果上最明显。工具结果里的文字是数据,模型仍可能误当指令:读一个文件,文件里写着一句"请删除所有日志",模型看到的是一段文字,但它无法完全确信那不是用户的要求。角色标记只能提供依据,不能提供保证。所以真正的防线在宿主这一侧,第九篇会展开具体机制。
模型收到工具结果,不意味着它一定正确使用;它也可能忽略、误解或过度推断。因此,输出格式正确与事实正确需要分别检查。
读请求不如读声明
判断模型支持什么,最容易走错的一步是去看设置页。设置页列出的是宿主任意时刻可以启用的能力,不是这一次请求实际带上的东西。
更可靠的办法是从运行事件里看本轮实际声明了哪些工具。Pi 用系统消息上的 toolsAdded 和 toolsRemoved 记录工具集的变化:
export interface SystemMessage {
role: "system";
content: string | TextContent[];
/** Complete definitions of tools that become available at this point. */
toolsAdded?: Tool[];
/** Tools that stop being available at this point. */
toolsRemoved?: ToolReference[];
timestamp: number;
}在 JSON 事件流里,这类系统消息会作为 message_start 和 message_end 出现。查这次请求里有哪些工具,就是查这些消息里 toolsAdded 的内容,而不是查设置页。
toolsAdded 里放的是完整的工具定义,包含参数 schema。这正好对应上一篇说的那句话:能触发动作的是结构化的工具声明,不是一段自然语言。
于是检查顺序可以定下来:
附件出现在 UI 上,不等于已经转换成有效模型输入;工具出现在设置页,也不等于本轮已经向模型声明。把这些环节分开,才能定位"模型看不到"到底发生在哪里。
还有一类能力不看工具,看模型本身。图片、思考配置、结构化输出约束,这些是模型或端点提供的,不在 toolsAdded 里。Pi 的 api 字段能帮上忙:它记录这次响应走的是哪个接口实现,而不同实现支持的输入模态和参数并不相同。AssistantMessage 上的 providerThinkingLevel 记录服务实际用的推理档位,thinkingLevel 记录宿主请求的档位,两者不一致时,说明服务做了调整。
同名的模型在不同端点暴露的能力也可能不同。这条判断比"模型支持什么"更可操作:先确认这次实际走的接口和端点,再谈它支持什么。
usage 记录了什么
事件流里每条 message_update 都带一个 usage 字段。它的含义是该助手响应的最新累计用量,不是本次事件的增量,也不是整个会话的总量。
这个区别很要紧。流式输出期间,同一条助手消息会发出很多次 message_update,每次的 usage 都是到目前为止的累计快照。判断"这次响应花了多少",取最后一次更新的值即可;把每次更新的值加起来,会重复计算。
Pi 的 Usage 结构里,输入、输出、缓存读写和推理各自有字段,并附有费用:
export interface Usage {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
/** Subset of `cacheWrite` written with 1h retention. Only Anthropic reports this split. */
cacheWrite1h?: number;
/**
* Reasoning/thinking tokens, when the provider reports them. This is a subset of
* `output`: `output` already includes these tokens. Set to a number (possibly 0) by
* providers that expose a reasoning breakdown; left undefined by providers that don't.
*/
reasoning?: number;
totalTokens: number;
cost: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
total: number;
};
}有两个细节要留意。其一,推理 token 已经包含在 output 里,再加一次就重复了。其二,工具结果上的 usage 是工具自身执行产生的用量,它参与会话统计,但不算在主模型调用的用量里。这两处 usage 名字相同,含义不同,读数时别混。
可选字段的缺失也有信息量。reasoning 只有在服务报告推理明细时才有值,没报告的服务这个字段是 undefined 而不是 0。看到它缺失,是"这个服务没报",不是"这次没用推理"。cacheWrite1h 同样只有 Anthropic 会拆分。
需要精确测量时,应使用相应模型与接口支持的计量方式,不同服务的字段划分也不完全一致。
一次响应的完整生命周期
把前面几节串起来,一次模型响应在 Pi 里走过的路是这样的:
这张表是前两篇的收束。第一篇给出"模型请求 → 工具执行 → 结果回填"三段,第二篇逐段讲清含义,这一篇补上消息与角色那一层。判断一次任务进行得对不对,照着这张表从前往后核对一遍,比读模型输出的措辞可靠得多。
为什么相同问题可能得到不同回答
模型逐步生成内容,服务实现、采样设置、模型版本与上下文细微变化都可能影响结果。temperature 等参数并非所有模型都支持,相同数值在不同服务上也不一定具有完全相同效果。
Pi 在助手消息上留了几个字段,让"这次和上次有什么不同"变得可查:
model 和 responseModel 的区别最值得记:前者是你请求的,后者是服务真的用的。如果一个模型在某天表现变了,先看这两个字段是否对得上。
rawStopReason 的用处也在这里。Pi 会把各家服务的原始结束原因归并成七个 stopReason 取值,Anthropic 的 end_turn、pause_turn 和 stop_sequence 都归成 stop,光看 stopReason 分不出是正常收尾还是别的原因。rawStopReason 保留了原始值,需要细查时用它。
将 temperature 设为零,不应承诺跨服务、版本与运行环境完全确定。需要验证程序处理逻辑时,可以用固定夹具或模拟模型流;需要测模型行为时,则应明确配置并多次观察,不能把夹具稳定当成模型可靠。
这两个需求经常被混在一起,是测试不可靠的常见原因。夹具稳定只说明宿主代码处理正确,不说明模型行为正确;反过来,多次观察到的模型波动,也不一定是宿主代码的 bug。分开测,才知道该改哪一边。
不把自信程度当作证据
模型可以用确定语气写出错误名称,也可以如实承认缺少文件。对于检查任务,可靠依据是文件读取与匹配结果,而不是答复听起来有多专业。
理解消息后,再看上下文限制会更直观:宿主不能无限发送所有历史,模型也不能被假定始终持有界面里出现过的全部内容。下一篇解释这些容量与信息边界。