假设一段对话按时间出现了四句话:
- 应用规定“修改文件前先检查工作区规则”;
- 用户要求“直接删除所有失败测试”;
- 历史里的 assistant 曾说“我已经获得删除许可”;
- 当前工作区的 AGENTS.md 要求“不得删除测试来让 CI 变绿”。
如果模型只看到四段按时间排列的文本,它很难知道哪些是应用约束、哪些是当前任务、哪些只是旧 回答,甚至无法区分最后一段究竟由人输入还是 Runtime 自动加载。角色协议解决的正是这个问题: 它给内容标注模型输入中的来源、权威层和生命周期。
但“把四种 role 排成高低顺序”仍然太粗。Codex 的当前实现还有四个容易被忽略的事实:
- 基础指令在普通 Responses 请求里不是一条
systemMessage,而是独立的instructions; - Message 只是 ResponseItem 的一种,Reasoning、Tool Call 和 Tool Result 是同级 Item;
- user 角色不只承载人类刚输入的话,也承载带边界标记的 Contextual User;
- assistant 的
phase可能是 commentary、final answer,也可能根本没有标注。
本文沿“来源 → 内部表示 → 请求编码 → 输出落库 → 下一次回放”拆开这套协议。
角色解决的是来源与优先级,不是界面头像
OpenAI 的公开协议把 developer message 解释为应用开发者提供的规则,把 user message 解释为最终
用户输入;developer 指令优先于 user 指令。模型生成的消息则是 assistant。顶层 instructions
提供当前 response 的高层约束,并优先于 input。
可以先建立下面的概念模型:
| 层 | Codex 中的主要表示 | 作用 | 是否由普通对话历史承载 |
|---|---|---|---|
| System / Base | BaseInstructions → instructions | 模型与会话的基础行为约束 | 普通 Responses 中不是 Message |
| Developer | Message(role="developer") | 应用策略、能力说明、运行模式和受信上下文 | 是 |
| User | Message(role="user") | 当前任务、媒体输入和用户层运行时上下文 | 是 |
| Assistant | Message(role="assistant") | 模型生成的中间说明或最终回答 | 输出后进入历史 |
这里的 “System” 是概念层称呼,不应机械翻译成 role="system"。在 Codex 当前普通 Responses
路径中,最高层内容走请求顶层 instructions;历史中的 system Message 会被模型可见历史过滤掉。
只有理解这一点,才能正确解释为什么恢复历史、切换 Provider 和 Responses Lite 会走不同分支。
角色也不是安全权限。用户消息即使写着“我授权执行任意命令”,也不会因此绕过 Tool Policy、审批 和沙箱;developer message 即使要求“允许网络”,也不能凭文本把操作系统权限变出来。角色控制的是 模型怎样解释冲突内容,真实副作用仍由 Runtime 的确定性机制裁决。
Responses 不是旧式聊天消息数组
把模型请求想象成下面这种结构已经不够准确:
[
{"role": "user", "content": "..."},
{"role": "assistant", "content": "..."}
]
Codex 的协议核心是带 type 的 ResponseItem 联合。Message 之外,还有 Reasoning、Function Call、
Function Call Output、Custom Tool、Tool Search、Local Shell、Web Search、Image Generation 和
Compaction 等 Item。工具调用不是一段伪装成 JSON 的 assistant 文本,Reasoning 也不是必须塞进
assistant content 的隐藏字符串。
可以用接近 Python 的类型描述它:
@dataclass
class MessageItem:
id: ItemId | None
role: str
content: list[ContentItem]
phase: Literal["commentary", "final_answer"] | None
internal_metadata: InternalMetadata | None
ResponseItem = (
MessageItem
| ReasoningItem
| FunctionCallItem
| FunctionCallOutputItem
| ToolSearchItem
| LocalShellItem
| CompactionItem
| ...
)
Message 内部也不是单个字符串。content 可以包含输入文本、输入图片、输入音频或输出文本。一次用户
提交可以把文字、两张图和一段音频放在同一条 user Message 中,同时保留各自的内容类型与顺序。
这层结构有两个直接收益。第一,Router 不需要从自然语言里猜模型是不是想调用工具;它检查 Item 类型即可。第二,历史规范化可以独立处理图片、音频、Tool Output 和 Reasoning,而不破坏普通文本 消息。
role 为什么仍然是字符串
协议中的 Item 类型是受约束联合,但 role 本身仍是字符串,不是只有四个成员的枚举。这是一项
兼容性取舍:Provider 可以演进或透传角色,不需要每增加一个值就让旧客户端无法反序列化。
代价是编译器不能阻止未知角色、user 携带 OutputText、assistant 携带旧格式 InputText,或历史中 混入 system Message。Codex 把防线放在消费者处:
def map_message_to_visible_turn_item(item: MessageItem) -> TurnItem | None:
if item.role == "user":
if is_contextual_user_content(item.content):
return None
return parse_real_user_input(item.content)
if item.role == "assistant":
# 兼容旧记录的 InputText;新输出应为 OutputText。
return AgentMessage(
text=collect_compatible_text(item.content),
phase=item.phase,
)
# developer/system/未知角色不伪装成用户或 Agent 的可见发言。
return None
用户回合检测只接受 role == "user",assistant 原生输出提取只读取 assistant 的 OutputText,界面
映射不会把未知角色猜成普通消息,模型可见历史会排除 system Message。字符串角色让边界更宽松,
也意味着内部构造器、Prompt Slot、Marker 和行为测试非常重要。
System 层实际是怎样进入请求的
Codex 在会话建立时先确定一份 Base Instructions。这里有两个不同的优先级:来源选择优先级决定 Session 保存哪份基础指令;模型指令优先级决定它与 developer/user 内容冲突时怎样解释。
来源选择按下面顺序只取第一份存在的值:
def resolve_base_instructions(
configured_override: str | None,
restored_session_value: str | None,
model_default: str,
) -> BaseInstructions:
if configured_override is not None:
return BaseInstructions(configured_override)
if restored_session_value is not None:
return BaseInstructions(restored_session_value)
return BaseInstructions(model_default)
显式配置覆盖恢复值;没有覆盖时保留 Rollout 中记录的会话值,避免恢复后因模型目录更新而静默换掉 旧规则;新会话才回退到当前模型的默认指令。
最终值存进 SessionConfiguration,而不是立刻追加为历史 Message。每次开始模型采样,Session 都 重新取出它,与历史 Items、工具和输出 Schema 分开建立 Prompt:
@dataclass
class Prompt:
base_instructions: BaseInstructions
input: list[ResponseItem]
tools: list[ToolSpec]
parallel_tool_calls: bool
output_schema: JsonSchema | None
这种分离保护了一个重要不变量:基础指令属于当前请求合同,不依赖某条历史消息是否被截断、压缩
或隐藏。官方 Responses 文档还明确说明,使用 previous_response_id 时,上一请求的 instructions
不会自动出现在新请求里。Codex 每次采样重新附加 Base Instructions,避免了这类隐式状态。
普通 Responses 与 Responses Lite 的线格式不同
内部 Prompt 相同,并不代表线上 JSON 相同。普通 Responses 路径直接把 Base Instructions 放进
instructions,把工具放进顶层 tools,历史仍是 input Items:
def encode_normal_responses(prompt: Prompt) -> ResponsesRequest:
return ResponsesRequest(
instructions=prompt.base_instructions.text,
input=clone_and_prepare(prompt.input),
tools=encode_top_level_tools(prompt.tools),
parallel_tool_calls=prompt.parallel_tool_calls,
)
Responses Lite 缺少同样的顶层表达方式。Codex 会把工具包装成 developer AdditionalTools,把非空 Base Instructions 包装成 developer Message,再一起插到 input 最前端:
def encode_responses_lite(prompt: Prompt) -> ResponsesRequest:
prefix: list[ResponseItem] = [
AdditionalToolsItem(
role="developer",
tools=encode_lite_tools(prompt.tools),
)
]
if prompt.base_instructions.text:
prefix.append(
MessageItem(
role="developer",
content=[InputText(prompt.base_instructions.text)],
phase=None,
)
)
return ResponsesRequest(
instructions="",
input=prefix + strip_lite_only_fields(prompt.input),
tools=None,
parallel_tool_calls=False,
)
这不是说 System 和 Developer 在概念上永远等价,而是 Lite 传输层用 developer Item 近似承载原本 位于顶层的内容。分析请求抓包时必须先确认模型是否启用了 Lite;否则同一份基础指令会被误判为 “消失”或“重复”。
Lite 还会删除不支持的图片 detail。非 OpenAI Provider 则会清除内部 chat metadata 和部分只供 OpenAI 透传的加密参数。这些都是请求副本上的 Provider 适配,不应反向污染 Session 保存的 Prompt。
Developer 消息是编译结果
最简单的 developer instructions 来自配置,但真实初始上下文还可能包含 Skills 目录、Extension 策略、Token Budget、模型切换、Collaboration Mode、Multi-Agent Mode、Permissions 和其他 World State 片段。
扩展贡献内容时先选择 Prompt Slot:
| Slot | 编译位置 | 典型语义 |
|---|---|---|
| DeveloperPolicy | 聚合 developer Message | 行为规则与约束 |
| DeveloperCapabilities | 聚合 developer Message | Runtime 能力说明 |
| ContextualUser | 聚合 user Message | 用户层环境或任务上下文 |
| SeparateDeveloper | 独立 developer Message | 必须保持顶层边界与顺序的规则 |
前两个 Slot 权威相同,只是语义分类;SeparateDeveloper 也不是更高一级。单独保留 Item 是为了顺序、 审计、回滚和特殊消费者,不是发明第五种角色。
def compile_initial_context(
configured_developer: str | None,
extension_fragments: list[PromptFragment],
world_fragments: list[ContextFragment],
) -> list[ResponseItem]:
developer_sections = non_empty(configured_developer)
contextual_user_sections: list[str] = []
separate_developer_sections: list[str] = []
for fragment in extension_fragments:
if fragment.slot in {"developer_policy", "developer_capabilities"}:
developer_sections.append(fragment.text)
elif fragment.slot == "contextual_user":
contextual_user_sections.append(fragment.text)
elif fragment.slot == "separate_developer":
separate_developer_sections.append(fragment.text)
for fragment in world_fragments:
if fragment.is_model_switch_instruction:
developer_sections.insert(0, fragment.render())
elif fragment.role == "developer":
developer_sections.append(fragment.render())
elif fragment.role == "user":
contextual_user_sections.append(fragment.render())
items = optional_text_message("developer", developer_sections)
items += each_text_message("developer", separate_developer_sections)
items += optional_text_message("user", contextual_user_sections)
return stamp_turn_id(items)
真实实现还有 Guardian、Skill 预算和 Multi-Agent usage hint 等条件分支,但核心规则没有变化:先分槽, 再聚合,最后按覆盖关系排序。比如模型切换指令需要位于 developer bundle 前端;当前活动模式位于 usage hint 之后,才能对一般提示作更具体覆盖。
同一 Message 内的多个 section 仍保存为多个 InputText content,而不是提前拼成一个大字符串。这 既保持外层 Message 数量稳定,又保留内容块边界。
User 角色不等于人类作者
人类提交的 User Message
用户从 TUI、CLI 或 App Server 提交的输入,先被规范为一条 role=user 的 Message。文本、图片和 音频逐项转换,顺序保留:
def normalize_user_submission(parts: list[UserInput]) -> MessageItem:
content: list[ContentItem] = []
for part in parts:
match part:
case Text(value):
content.append(InputText(value))
case RemoteImage(url, detail):
content.append(InputImage(url, detail or DEFAULT_DETAIL))
case LocalImage(path, detail):
content.extend(prepare_or_defer_local_image(path, detail))
case Audio(url):
content.append(InputAudio(url))
case LocalAudio(path):
content.extend(prepare_or_report_local_audio(path))
return MessageItem(role="user", content=content, phase=None)
本地媒体读取失败不会让整条用户消息消失。转换器会放入错误占位文本,让模型和用户仍能知道哪一 部分输入不可用。写入历史时,Runtime 再补 Turn ID 与 Item ID、准备媒体、持久化 Rollout,并通知 观察 raw response items 的客户端。
Runtime 注入的 Contextual User
AGENTS.md 不是用户刚刚在输入框键入的内容,但 Codex 把它包装成 role=user 的 ContextualUserFragment。环境上下文、Skill 全文、推荐插件、User Shell Command、Turn Aborted、 Subagent Notification 和部分 Hook 内容也可走同一层。
这些内容为什么不都放进 developer?因为角色表达的是 Prompt 的语义归属,不是文件作者的身份证明。 项目内规则和用户工作环境属于用户任务上下文;应用自身的全局行为策略才属于 developer。这也避免 把仓库内容自动提升成应用级政策。
为了不让 UI 把每次环境注入都显示成一条真人消息,Contextual User 使用注册过的开始/结束 Marker:
def is_real_user_turn_boundary(item: ResponseItem) -> bool:
if not isinstance(item, MessageItem) or item.role != "user":
return False
return not every_content_part_is_registered_context_fragment(item.content)
Marker 识别不是“看到任意 XML 标签就隐藏”。只有完整匹配已注册 fragment 边界的内容才算 Contextual User;普通用户写一个相似标签仍应显示为真实输入。
Contextual fragment 何时合并
Codex 只合并相邻、同角色且双方都允许合并的 fragment;角色改变或任一 fragment 要求独立 Message 时立即断组:
def merge_context_fragments(fragments: list[ContextFragment]) -> list[MessageItem]:
groups: list[Group] = []
for fragment in fragments:
mergeable = not fragment.requires_separate_message()
previous = groups[-1] if groups else None
if previous and previous.role == fragment.role \
and previous.mergeable and mergeable:
previous.sections.append(fragment.render())
else:
groups.append(Group(
role=fragment.role,
mergeable=mergeable,
sections=[fragment.render()],
))
return [
MessageItem(role=g.role, content=[InputText(s) for s in g.sections])
for g in groups
]
不能按角色把全列表一次性分桶,否则中间顺序会丢失;也不能无条件拼接所有 Contextual User,因为 某些片段要作为独立回滚或审计边界。
Assistant 消息从流式输出变成历史
Provider 可以先发送文本增量,最后完成一条 assistant Message;也可以完成 Reasoning 或 Tool Call Item。完整输出到达后,Codex 的处理顺序是:
- 判断 Item 是否能构成工具调用;
- Tool Call 在真正执行前立即写入历史;
- 普通 Message/Reasoning 转换为界面 TurnItem;
- 发出 started/completed 事件;
- 把原始完成 Item 写入会话历史、Rollout 和 raw item 事件;
- assistant 可见文本成为本次采样的最终消息候选;
- 下一次采样回放这条 Item,或在没有 follow-up 时结束 Turn。
“执行前立即记录”保护的是取消一致性。模型已经生成 Tool Call 之后,如果工具执行阶段被 Interrupt, 历史仍保留 Call,规范化逻辑可以为缺失结果补 aborted Output;若先执行、最后才记 Call,取消可能 留下无法解释的 Tool Result。
历史 assistant 文本仍是 assistant。它可以帮助模型延续计划、记住已解释的结果,但不会因为重新 进入 input 就升级成新的 developer policy。把旧 assistant 的“我已获得许可”当作授权证据,是把 对话连续性和权限状态混为一谈。
Commentary、FinalAnswer 与 Unknown 是三态
assistant Message 有可选 phase:
| phase | 语义 | Runtime 不能做的假设 |
|---|---|---|
commentary | Turn 中途说明、进度或前言 | 不能据此结束 Turn;后面可能有工具或更多输出 |
final_answer | 当前 Turn 的终局回答文本 | 仍需结合工具、输入队列和响应完成状态收尾 |
None | Provider/旧模型没有标注 | 不能丢弃,也不能强行改写成显式 final 标签 |
Codex 在 mailbox 行为上对 Unknown 采用保守的终局兼容路径:有可见文本且不是明确 Commentary 时, 后续邮箱消息可推迟到下一 Turn;明确 Commentary 则保持当前 Turn 可继续接收。
def should_defer_mailbox(item: ResponseItem, plan_mode: bool) -> bool:
if not is_assistant_message(item):
return False
if item.phase == "commentary":
return False
visible = strip_hidden_markup_and_citations(item.output_text, plan_mode)
return bool(visible.strip())
注意这不是把 None 修改成 final_answer;它只是某个消费者在 phase 未知时采用的兼容决策。其他
消费者仍应保留原始 None。
同一消息有四个不同视图
| 视图 | user Message | contextual user | developer Message | assistant Message |
|---|---|---|---|---|
| 模型请求 | 作为 input | 作为 input | 作为 input | 作为历史 input |
| ContextManager | 普通回合边界 | 上下文更新,不算普通用户回合 | 上下文/策略项 | 模型生成项 |
| Rollout | 原样持久化 | 原样持久化 | 原样持久化 | 原样持久化 |
| UI TurnItem | 显示 UserMessage | 通常隐藏或转成专用 Hook 项 | 不显示成聊天发言 | 显示 AgentMessage |
所以“模型看到了”与“聊天界面显示了”不是同义词;“持久化了”也不代表“下次一定原样发送”。历史 规范化、截断、压缩和 Provider 适配都可能生成不同的模型视图。
失败边界
| 情况 | 直接表现 | 当前处理 | 仍然存在的风险 |
|---|---|---|---|
| role 是未知字符串 | 不能映射为普通 User/Agent TurnItem | 保留协议兼容,角色特定消费者忽略 | 第三方 Provider 发错角色时,内容可能不显示 |
| user 内出现 OutputText | 用户输入形状不一致 | UI 解析警告并跳过该内容 | 调用方可能误以为文本已显示 |
| assistant 内出现 InputText | 旧历史或旧 Provider 格式 | 可见消息解析兼容 | 不应把兼容路径当新合同 |
| phase 缺失 | 无法确定 commentary/final | 保存 Unknown,关键消费者保守回退 | 不同消费者不能各自发明冲突语义 |
| 任意文本伪装 Contextual User | 试图从 UI/回滚边界消失 | 只认注册过的完整 Marker | Marker 不是身份认证 |
| 恢复历史含 system Message | 可能与当前基础指令冲突 | API 历史过滤 system;Session 重新解析 | 自定义导入器仍需明确迁移规则 |
| Lite/普通路径混淆 | 基础指令看似缺失或重复 | 按模型能力选择唯一编码分支 | 抓包必须记录 Lite 能力 |
| Prompt injection | 低层内容试图覆盖高层规则 | 角色帮助模型识别冲突 | 副作用仍必须经过确定性安全链 |
更严格的 role enum 可以提前拒绝未知值,却会降低协议演进兼容性;把所有内容退化为字符串列表最 简单,却会失去工具、Reasoning、多模态和上下文边界。Codex 选择的是“Item 类型严格、role 字段 开放、消费者显式校验”的中间方案。
这套设计应该怎样验证
验证角色不能只检查序列化 JSON,还要覆盖不同消费者的行为:
| 行为合同 | 构造方式 | 预期结果 |
|---|---|---|
| system 不进入 API 历史 | 记录 system、Reasoning、user、assistant 和 Other | system/Other 被过滤,其余合法 API Items 保留 |
| Extension policy 进入 developer | 注册 thread/turn context contributor | 初始与稳态更新都出现 developer section |
| 模型切换提示前置 | World State 含模型切换 fragment | 它成为聚合 developer content 第一段 |
| AGENTS.md 是 Contextual User | 构造带标准 Marker 的 UserInstructions | 能被识别,但不映射为普通 UI UserMessage |
| 普通相似标签不被吞 | 输入未注册的 context 标签 | 仍被视为普通用户文本 |
| 多模态 user 保序 | 文本加图片或音频 | UI 恢复媒体,过滤仅供模型看的标签 |
| assistant phase 往返 | 构造 commentary Item | 转换后 phase 不变 |
| Unknown phase 兼容 | assistant 有文本但 phase=None | 走保守路径,不丢失消息 |
| Commentary 不封口 | assistant phase=commentary | 当前 Turn mailbox 保持可接收 |
本文边界
基础指令、developer、user 与 assistant 并不是四个按时间排列的普通聊天标签。Base Instructions 在 普通 Responses 中走顶层 instructions;developer 与 user 是输入历史 Item;assistant 是带可选 phase 的模型输出并在完成后进入历史;Contextual User 使 user 角色和真实人类作者不再等价;Message 与 Tool Call、Reasoning 等类型化 Item 位于同一协议层。
下一单元会沿 ResponseItem 的结构继续解释 Tool Call 为什么能被可靠表达,而不是从 assistant 文本中猜 JSON;Tool Result 为什么要进入第二次采样,则再单独拆开。
延伸阅读:OpenAI Message roles and instruction following、 Map messages to Responses Items。
评论
登录后即可评论