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

模型如何接收任务:消息、角色与能力边界

理解消息来源、角色、工具结果和模型支持范围,避免把流畅输出当作事实保证。

模型如何接收任务:消息、角色与能力边界

用户在界面里输入一句话,模型收到的往往不止这一句话。宿主还可能加入任务规则、历史消息、工具声明和文件内容。理解模型的行为,先要知道这次请求里有什么。

上一篇走完了一次执行循环:模型提出请求,宿主执行,结果回填。那条结果最终变成什么、放进这次请求的哪个位置,取决于消息的类型和角色。这一篇接着往下看。

沿用贯穿本组文章的教学文件 src/greet.ts,它是一个教学设定,不代表已经对你的项目执行过检查。

消息不只是聊天气泡

沿着文件检查任务,可以把输入分成四类。Pi 的底层消息类型正好对应四种角色:system、user、assistant、toolResult。

来源与用途教学内容这一类里的内容,模型会当成什么需要注意
系统或应用说明
只读检查,依据工具结果报告
当成规则:必须遵守的约束
由应用组织,不是文件正文
用户任务
检查 src/greet.ts
当成目标:要完成的请求
说明目标、范围和要求
助手输出
提出 read 调用
当成自己上一轮说过的话
可能是动作请求,不只是文本
工具结果
实际匹配到 greet
当成数据:一次执行的返回值
是执行数据,不是新的用户授权

第三列是这张表最值得记住的一列。同样的自然语言,出现在不同角色里,模型对它的处理方式并不一样。工具结果里的文字是数据,模型可能把它当成观察结论,也可能误当成新的指令。第九篇会展开这一点,以及宿主该怎么防。

另外,界面中的状态通知、进度动画或内部缓存,未必会进入模型请求。反过来,用户看不见的系统说明可能影响模型行动。排查时应检查有效请求,而不是只看截图里的聊天文字。

这四类消息在 Pi 里都由同一个联合类型收着:

ts
export type Message = SystemMessage | UserMessage | AssistantMessage | ToolResultMessage;

assistant 这一类值得多看一眼。它的 content 不是字符串,而是内容块的数组,块有三种:TextContent、ThinkingContent、ToolCall。同一个助手消息里可以既有文字又有工具调用块,这解释了上一篇里"助手输出可能是动作请求,不只是文本"在数据结构上是怎么成立的。下面是定义里与本篇相关的字段,省略了其余可选字段:

ts
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 是这段类型里最该记住的一个字段,它有七种取值:

取值表示什么
pending
还在流式输出中,这是部分消息
stop
正常生成结束
length
撞到输出上限被截断
toolUse
模型要求调用工具,循环会继续
error
这一轮出错
aborted
被中止
deferred
服务把响应推迟了,需要凭 DeferredHandle 取回

stopReason 为 stop 只说明这次生成结束了,不说明任务完成了。上一篇说的"模型停止输出"和"任务真的完成"是两件事,在这里有了对应的字段可以分开看。

角色名各不相同,先对上号再说

不同接口用的角色名和结构不一样,读源码时容易看错。同一件事,在 Pi 内部和各家接口上是三种形状:

概念Pi 内部Anthropic MessagesOpenAI Responses
工具调用
assistant 消息里的 toolCall 块
assistant 消息里的 tool_use 块
assistant 输出里的 function_call 项
工具结果
独立的 toolResult 消息
user 消息里的 tool_result 块
function_call_output 项
结果指向哪次调用
toolCallId
tool_use_id
call_id

第三行值得单独说。Pi 把工具结果做成一条独立消息,角色是 toolResult;Anthropic 把它塞进一条 user 消息里,用 tool_result 内容块表示;OpenAI Responses 用 function_call_output 项,并且它带的 call_id 可能和函数调用项的 id 不是同一个值,宿主需要自己把它们对上。

这不是实现细节的偶然差异,而是各家 API 的规定。Pi 内部统一用 toolCallId,转换发生在适配器里。上一篇提到的 id 与 toolCallId 的关系,在这里延伸成了"同一个工具调用在三种接口上有三种写法",读源码时按这张表核对即可。

把一次请求摊开看

文件检查任务走到第二次模型请求时,宿主发出去的内容大致是这样。这是按 Pi 内部结构写的教学示例,不是某个服务的真实报文:

json
[
  {
    "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 记录工具集的变化:

ts
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。这正好对应上一篇说的那句话:能触发动作的是结构化的工具声明,不是一段自然语言。

于是检查顺序可以定下来:

希望完成的动作至少需要确认到哪里确认
分析图片
模型和当前端点支持相应输入,宿主确实发送了附件
助手消息里的内容块
使用工具
模型能表达调用,宿主注册并允许执行对应工具
系统消息的 toolsAdded
返回严格结构
服务支持对应约束,程序还会验证业务内容
响应里的 stopReason 与宿主校验
检查本机文件
宿主提供读取能力,路径与权限允许
工具结果里的 isError 与内容

附件出现在 UI 上,不等于已经转换成有效模型输入;工具出现在设置页,也不等于本轮已经向模型声明。把这些环节分开,才能定位"模型看不到"到底发生在哪里。

还有一类能力不看工具,看模型本身。图片、思考配置、结构化输出约束,这些是模型或端点提供的,不在 toolsAdded 里。Pi 的 api 字段能帮上忙:它记录这次响应走的是哪个接口实现,而不同实现支持的输入模态和参数并不相同。AssistantMessage 上的 providerThinkingLevel 记录服务实际用的推理档位,thinkingLevel 记录宿主请求的档位,两者不一致时,说明服务做了调整。

同名的模型在不同端点暴露的能力也可能不同。这条判断比"模型支持什么"更可操作:先确认这次实际走的接口和端点,再谈它支持什么。

usage 记录了什么

事件流里每条 message_update 都带一个 usage 字段。它的含义是该助手响应的最新累计用量,不是本次事件的增量,也不是整个会话的总量。

这个区别很要紧。流式输出期间,同一条助手消息会发出很多次 message_update,每次的 usage 都是到目前为止的累计快照。判断"这次响应花了多少",取最后一次更新的值即可;把每次更新的值加起来,会重复计算。

Pi 的 Usage 结构里,输入、输出、缓存读写和推理各自有字段,并附有费用:

ts
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 里走过的路是这样的:

阶段消息或事件上的标志这时能确认什么
请求构造
系统消息的 toolsAdded
本轮向模型声明了哪些工具
流式输出
message_update,stopReason 为 pending
还没说完,内容是部分的
生成收尾
message_end,stopReason 为 stop 或 toolUse
这次生成结束;要工具还是要收尾
工具执行
tool_execution_start 到 tool_execution_end
动作是否真的执行,结果是否成功
结果回填
一条 role 为 toolResult 的消息
结果进了模型能看到的地方
下一轮
又一次 message_start
模型带着新结果重新决策

这张表是前两篇的收束。第一篇给出"模型请求 → 工具执行 → 结果回填"三段,第二篇逐段讲清含义,这一篇补上消息与角色那一层。判断一次任务进行得对不对,照着这张表从前往后核对一遍,比读模型输出的措辞可靠得多。

为什么相同问题可能得到不同回答

模型逐步生成内容,服务实现、采样设置、模型版本与上下文细微变化都可能影响结果。temperature 等参数并非所有模型都支持,相同数值在不同服务上也不一定具有完全相同效果。

Pi 在助手消息上留了几个字段,让"这次和上次有什么不同"变得可查:

字段记录什么什么时候它和你的预期不一致
api
这次走的接口实现
换成了另一个接口实现
provider
服务提供方
经过了代理或兼容层
model
请求的模型
这是请求值,不保证服务实际用它
responseModel
服务实际返回的模型
服务做了替换或降级
providerThinkingLevel
服务实际用的推理档位
服务按自己的规则调了档位
thinkingLevel
宿主请求的推理档位
与上一项不一致即说明被调整过
rawStopReason
服务原始的结束原因
服务用了多个原始原因映射到同一个 stopReason

model 和 responseModel 的区别最值得记:前者是你请求的,后者是服务真的用的。如果一个模型在某天表现变了,先看这两个字段是否对得上。

rawStopReason 的用处也在这里。Pi 会把各家服务的原始结束原因归并成七个 stopReason 取值,Anthropic 的 end_turn、pause_turn 和 stop_sequence 都归成 stop,光看 stopReason 分不出是正常收尾还是别的原因。rawStopReason 保留了原始值,需要细查时用它。

将 temperature 设为零,不应承诺跨服务、版本与运行环境完全确定。需要验证程序处理逻辑时,可以用固定夹具或模拟模型流;需要测模型行为时,则应明确配置并多次观察,不能把夹具稳定当成模型可靠。

这两个需求经常被混在一起,是测试不可靠的常见原因。夹具稳定只说明宿主代码处理正确,不说明模型行为正确;反过来,多次观察到的模型波动,也不一定是宿主代码的 bug。分开测,才知道该改哪一边。

不把自信程度当作证据

模型可以用确定语气写出错误名称,也可以如实承认缺少文件。对于检查任务,可靠依据是文件读取与匹配结果,而不是答复听起来有多专业。

理解消息后,再看上下文限制会更直观:宿主不能无限发送所有历史,模型也不能被假定始终持有界面里出现过的全部内容。下一篇解释这些容量与信息边界。

资料依据