Message、Role 与消息顺序
学习目标
- 把
Message看成可审计的数据记录,而不是一段拼接好的字符串。 - 区分
system、developer、user、assistant和tool的来源与信任边界。 - 用追加式历史保持消息顺序、关联标识和基本轮次不变量。
- 读框架源码时,能从消息归一化、序列化和回填处定位模型调用链。
核心知识点
模型通常接收一个有序消息序列,而不是“当前问题”一个字符串。每条消息至少有角色、内容和可选关联信息;内容在下一篇会从字符串扩展为文本、图片、音频等 Content-Block。消息历史描述“到目前为止发生了什么”,而系统状态、用户身份和持久化会话是更大的边界,不能偷偷塞进 content。
可以把一轮对话想成一条不可随意重排的日志:
阅读提示:消息按追加顺序形成输入;tool 结果必须带 tool_call_id 回填,不能把外部结果改写成没有来源的用户文本。
本文沿用Model、Provider 与 Model-Adapter中的规范化调用边界,但只处理消息层;工具如何选择和执行属于第 04 章。
Message:一个带来源的记录
Message 不只是 {role, content} 两个字段。生产系统通常还需要消息 ID、时间或顺序号、调用关联 ID、名称、可选元数据以及是否由系统生成等信息。字段越多,越要明确哪些字段会发送给模型、哪些字段只用于审计,避免把内部标记原样暴露给外部服务。
Message dataclass:表达最小数据模型
用途:用标准库数据类表达一条不可变消息,并把只有消息层才知道的关联字段保留下来。
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
Role = Literal["system", "developer", "user", "assistant", "tool"]
@dataclass(frozen=True)
class Message:
role: Role
content: str
message_id: str
tool_call_id: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
message = Message(
role="user",
content="解释消息顺序",
message_id="m-1",
)
print(message.role, message.message_id)
# 输出:user m-1Literal 只是示例级的静态约束,运行时仍要校验外部输入。metadata 不应成为隐形提示词通道;要发送给模型的字段应在序列化函数中显式选择。真实 SDK 可能把 tool_call_id 放在内容块或单独字段,阅读时看它是否承担同一个“结果属于哪一次调用”的关联责任。
Message.to_model_input:显式序列化
用途:只把模型协议需要的字段转换成字典,防止内部审计数据和敏感元数据越过调用边界。
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
Role = Literal["system", "developer", "user", "assistant", "tool"]
@dataclass(frozen=True)
class Message:
role: Role
content: str
message_id: str
tool_call_id: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
def to_model_input(self) -> dict[str, Any]:
payload: dict[str, Any] = {"role": self.role, "content": self.content}
if self.tool_call_id is not None:
payload["tool_call_id"] = self.tool_call_id
return payload
message = Message("user", "仅发送必要字段", "m-2", metadata={"trace_id": "private"})
print(message.to_model_input())
# 输出:{'role': 'user', 'content': '仅发送必要字段'}序列化是安全边界:模型需要的 name、内容块或供应商专有字段,应通过显式转换添加;不能为了省事把 dataclasses.asdict(message) 整体发送。对敏感信息尤其要避免“日志有脱敏、请求没脱敏”或反过来的不一致。
Role:声明消息来源与处理语义
角色是协议语义,不是权限系统。某些供应商支持 developer,某些只提供 system;某些 SDK 把系统指令作为 Agent 配置而不是消息。适配器可以做映射,但必须记录映射规则和优先级,不能用字符串替换假装语义完全相同。
system:应用级行为约束
system 通常承载应用希望模型持续遵守的高层规则,例如输出语言、格式和安全边界。它不是绝对防火墙:模型可能误解或违反它,系统仍要在输出、工具权限和业务逻辑层校验。系统指令的版本和所有者应可追踪,具体组织方式见System Instructions 与 Prompt 边界。
developer:运行方的实现约束
某些协议将 developer 作为比用户消息更靠近应用的一层,用于产品行为和实现约束。它与 system 是否等价取决于供应商;不要在核心代码中硬编码“developer 一定优先”。若适配器需要折叠角色,应把折叠后的顺序写成测试。
user:外部目标与数据
user 表示用户输入或应用代替用户提交的任务上下文。它可能包含恶意提示注入,也可能包含引用的文档、网页和日志。角色标签不能证明内容可信;外部文本仍应被当作数据处理,并在 Prompt 边界和输出验证处设防。
assistant:模型产生的消息
assistant 是模型输出被系统记录后的消息。它不等于“已经执行的事实”:模型说“已完成”不能替代真实工具结果、数据库状态或业务校验。重放历史时,只有经过系统确认的 assistant 消息才应进入后续上下文,失败或被拦截的草稿要有明确状态。
tool:带关联 ID 的外部结果
tool 消息表示某个外部调用的返回数据,必须能关联到先前的调用标识。它属于消息层的“结果回填”,不在此展开工具注册、参数 schema 或执行权限。工具返回内容即使带有“请忽略之前指令”等文字,也应被当作不可信数据,不自动提升为系统指令。
用途:用一个专门构造函数保证工具结果携带关联 ID,并拒绝把调用 ID 混在正文里。
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
Role = Literal["tool"]
@dataclass(frozen=True)
class Message:
role: Role
content: str
message_id: str
tool_call_id: str | None = None
def tool_message(call_id: str, result: str, message_id: str) -> Message:
if not call_id.strip():
raise ValueError("tool result needs a call id")
# 关键输入:result 是外部数据,call_id 是消息关联键。
return Message("tool", result, message_id, tool_call_id=call_id)
message = tool_message("call-7", "天气数据:晴", "m-7")
print(message.role, message.tool_call_id, message.content)
# 输出:tool call-7 天气数据:晴第 04 章会讲完整的 Tool Calling 闭环;当前只要求读懂“结果必须以可关联的消息回填”,不要把 tool 角色当成“模型可以直接执行的命令”。
消息顺序与轮次不变量
顺序改变,模型看到的事实就可能改变。消息列表应该追加而不是原地排序;截断、摘要和重试都必须保留系统规则、用户目标与相关结果之间的关系。为了支持审计,可以把顺序号或消息 ID 固化,而不是只依赖列表下标。
Conversation.append:只追加新消息
用途:通过不可变历史和追加方法表达“顺序一旦确认就不回写”的约束。
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
Role = Literal["system", "developer", "user", "assistant", "tool"]
@dataclass(frozen=True)
class Message:
role: Role
content: str
message_id: str
tool_call_id: str | None = None
@dataclass(frozen=True)
class Conversation:
messages: tuple[Message, ...] = ()
def append(self, message: Message) -> "Conversation":
# 关键状态变化:返回新历史,旧历史可用于审计或重试。
return Conversation(self.messages + (message,))
history = Conversation().append(Message("user", "第一问", "m-1"))
next_history = history.append(Message("assistant", "第一答", "m-2"))
print(len(history.messages), len(next_history.messages))
# 输出:1 2追加式历史不是说永远不能压缩,而是说压缩应产生新的版本或明确事件。这样一次失败重试可以复用旧输入,不会因为并发请求把另一条分支的消息插进来。
validate_order:检查基本合法性
用途:在发送前检查第一条系统消息位置和工具结果关联,提前发现会导致协议错误的历史。
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
Role = Literal["system", "developer", "user", "assistant", "tool"]
@dataclass(frozen=True)
class Message:
role: Role
content: str
message_id: str
tool_call_id: str | None = None
def validate_order(messages: tuple[Message, ...]) -> None:
if not messages:
raise ValueError("message history cannot be empty")
system_positions = [i for i, item in enumerate(messages) if item.role == "system"]
if system_positions and system_positions[0] != 0:
raise ValueError("system message must be first in this profile")
for item in messages:
if item.role == "tool" and not item.tool_call_id:
raise ValueError(f"tool message {item.message_id} has no call id")
# 输出契约:函数无返回值表示基础顺序和关联检查通过。
valid = (
Message("system", "只回答事实", "m-0"),
Message("user", "问题", "m-1"),
)
validate_order(valid)
print("order valid")
# 输出:order valid这不是一套适用于所有供应商的完整状态机。某些协议允许多条系统消息、把开发者消息移动到独立字段,某些模型输出的工具调用需要中间 assistant 记录。校验器应绑定到已选协议 profile,并通过测试锁定行为,而不是把本地偏好写成普遍定律。
message_id 与关联键:审计而不是装饰
消息 ID 用于追踪、去重和引用;tool_call_id 用于把结果对应到一次调用。两者不要混用:同一消息可有自己的 ID,同时携带外部调用 ID。若消息通过队列重放,重复 ID 可用于去重,但去重前要区分“同 ID 同内容重放”和“同 ID 不同内容冲突”。
用途:检测消息 ID 冲突,防止两条不同内容伪装成同一历史记录。
from dataclasses import dataclass
@dataclass(frozen=True)
class Message:
message_id: str
role: str
content: str
def index_messages(messages: tuple[Message, ...]) -> dict[str, Message]:
index: dict[str, Message] = {}
for message in messages:
previous = index.get(message.message_id)
if previous is not None and previous != message:
raise ValueError(f"conflicting message id: {message.message_id}")
index[message.message_id] = message
return index
messages = (Message("m-1", "user", "原始问题"), Message("m-1", "user", "原始问题"))
print(len(index_messages(messages)))
# 输出:1重复同内容通常是重放,重复 ID 不同内容则是数据完整性问题。日志中可以记录哈希或摘要,但不要为了去重把完整的隐私内容作为全局键。
消息历史、上下文与会话的边界
消息历史是“发送给模型的叙事”;Context 是本轮决定要发送的输入集合;Session 是跨调用保存历史、身份和恢复信息的生命周期对象。三者经常被同一个框架类包装,但源码阅读和故障排查时要分开问:哪一层决定了这条消息出现?哪一层持久化?哪一层在窗口不足时裁剪?
build_context:从历史生成一次模型输入
用途:展示会话历史不是自动等于本次上下文,调用方可以明确选择要发送的消息。
from dataclasses import dataclass
@dataclass(frozen=True)
class Message:
role: str
content: str
def build_context(history: tuple[Message, ...], keep_last: int) -> tuple[Message, ...]:
if keep_last < 1:
raise ValueError("keep_last must be positive")
system = tuple(item for item in history if item.role == "system")[:1]
recent = history[-keep_last:]
selected = system + tuple(item for item in recent if item not in system)
# 关键状态变化:历史被选择为本次输入,未选消息仍留在会话外部。
return selected
history = (
Message("system", "保持简洁"),
Message("user", "旧问题"),
Message("assistant", "旧回答"),
Message("user", "新问题"),
)
for item in build_context(history, 2):
print(item.role, item.content)
# 输出:system 保持简洁
# 输出:user 新问题示例只展示选择逻辑,实际裁剪还要考虑消息关联、Token 预算和内容块大小,见Token、上下文窗口与 Usage。keep_last 也可能把 system 消息重复选入,因此生产实现要有明确的去重和顺序策略;此处重点是把“历史”和“本次上下文”分开。
四个主线项目中的对应位置
smolagents:消息在模型调用与步骤记录之间流动
smolagents 的 Agent 步骤会把任务、模型输出和工具结果组织成可供下一步使用的上下文。阅读源码时重点观察:模型收到的是哪种规范化消息/文本表示,工具结果如何回填,哪些记录只是步骤日志而不是发送给模型的消息。不要把内部 step 对象自动等同于标准 Message。
OpenAI Agents SDK:输入消息与运行结果分层
OpenAI Agents SDK 允许 Agent 由 instructions、用户输入、工具结果和 handoff 组成运行上下文。源码阅读时先看输入如何被转换为模型消息,再看最终输出项和 trace 如何记录;Agent、RunResult 与底层 Message 的责任不同,不能只看公开的字符串结果。
LangGraph:消息通常是状态字段的一部分
LangGraph 可将消息列表放入图状态,并通过节点更新它。关键问题不是“图有没有一个 Message 类”,而是 reducer 如何追加、合并或覆盖消息,checkpoint 如何保存顺序,以及并行节点如何避免把两条分支错误拼成一轮对话。
DeepSeek Harness:事件和消息的时间顺序
DeepSeek Harness 的事件系统与会话/插件生命周期会产生比模型消息更多的记录。源码阅读时区分“用户/模型的消息”与“运行时事件”,追踪它们何时写入会话、何时转换为 LLM 输入;开发预览期接口可能变化,优先确认责任边界而不是背路径。
易混点
Role描述来源和协议语义,不等于认证授权;user也可能携带恶意内容。assistant说“已经完成”不是外部事实;只有真实结果回填才表示执行结果。tool消息需要关联 ID,但本文只讲消息层,不展开工具选择与执行。- 消息历史、一次 Context 和跨调用 Session 可能由同一对象包装,概念上仍应分开。
- 列表下标不是可靠审计 ID;重放和并发需要消息 ID、顺序和冲突策略。
- 重新排序、裁剪或合并消息都可能改变模型语义,必须有明确 profile 和测试。
课后小问
为什么工具结果不能直接拼进下一条用户消息?
答案:这样会丢失结果来源和调用关联,模型与审计系统无法知道它是否来自真实执行。保留
tool角色和调用 ID,才可以做回填、重放和错误定位。角色标成
system后,内容就一定可信且一定会被模型遵守吗?答案:不一定。角色只表达应用提交时的协议语义,不是防篡改或绝对安全边界。系统仍需限制谁能写入它,并校验模型输出和外部副作用。
为什么裁剪历史不能只取最后 N 条字符串?
答案:可能丢掉系统规则、用户目标或工具结果的关联,导致新上下文不完整。裁剪必须按消息结构、优先级、关联键和 Token 预算共同决定。
本节小结
Message 是带来源和关联信息的有序记录;Role 告诉协议如何解释来源,但不替代安全授权;历史顺序和消息关联是模型输入的基本不变量。把序列化、追加、校验和上下文选择分开,才能在换供应商、重试、裁剪和恢复时保持可解释性。
快速回顾
Message:角色、内容、ID、关联键和受控元数据。Role:system/developer/user/assistant/tool的来源语义。- 顺序:追加、验证、必要时生成新版本,不在原历史上静默重排。
- 关联:消息 ID 用于审计/去重,工具调用 ID 用于结果回填。
- 边界:历史 ≠ 本次上下文 ≠ 跨调用会话。
阿源的知识库