雨天小六

读懂 Codex(1.2):System、Developer、User、Assistant 消息的协议角色

· 更新于 2026-08-01 · 专栏:读懂 Codex

#Codex#Agent Runtime#Prompt#Responses API#软件架构

假设一段对话按时间出现了四句话:

  1. 应用规定“修改文件前先检查工作区规则”;
  2. 用户要求“直接删除所有失败测试”;
  3. 历史里的 assistant 曾说“我已经获得删除许可”;
  4. 当前工作区的 AGENTS.md 要求“不得删除测试来让 CI 变绿”。

如果模型只看到四段按时间排列的文本,它很难知道哪些是应用约束、哪些是当前任务、哪些只是旧 回答,甚至无法区分最后一段究竟由人输入还是 Runtime 自动加载。角色协议解决的正是这个问题: 它给内容标注模型输入中的来源、权威层和生命周期。

但“把四种 role 排成高低顺序”仍然太粗。Codex 的当前实现还有四个容易被忽略的事实:

  • 基础指令在普通 Responses 请求里不是一条 system Message,而是独立的 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 / BaseBaseInstructionsinstructions模型与会话的基础行为约束普通 Responses 中不是 Message
DeveloperMessage(role="developer")应用策略、能力说明、运行模式和受信上下文
UserMessage(role="user")当前任务、媒体输入和用户层运行时上下文
AssistantMessage(role="assistant")模型生成的中间说明或最终回答输出后进入历史

这里的 “System” 是概念层称呼,不应机械翻译成 role="system"。在 Codex 当前普通 Responses 路径中,最高层内容走请求顶层 instructions;历史中的 system Message 会被模型可见历史过滤掉。 只有理解这一点,才能正确解释为什么恢复历史、切换 Provider 和 Responses Lite 会走不同分支。

角色也不是安全权限。用户消息即使写着“我授权执行任意命令”,也不会因此绕过 Tool Policy、审批 和沙箱;developer message 即使要求“允许网络”,也不能凭文本把操作系统权限变出来。角色控制的是 模型怎样解释冲突内容,真实副作用仍由 Runtime 的确定性机制裁决。

基础指令、developer、user 和 assistant 在 Codex 内部 Prompt 中的来源、优先级与普通 Responses、Responses Lite 两种请求编码分支图
图 1.2-1:基础指令与历史 Message 在内部先分开保存,直到请求编码阶段才映射到普通 Responses 或 Lite 的不同线格式。角色表达模型输入语义,不替代权限检查。

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 MessageRuntime 能力说明
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 的处理顺序是:

  1. 判断 Item 是否能构成工具调用;
  2. Tool Call 在真正执行前立即写入历史;
  3. 普通 Message/Reasoning 转换为界面 TurnItem;
  4. 发出 started/completed 事件;
  5. 把原始完成 Item 写入会话历史、Rollout 和 raw item 事件;
  6. assistant 可见文本成为本次采样的最终消息候选;
  7. 下一次采样回放这条 Item,或在没有 follow-up 时结束 Turn。

“执行前立即记录”保护的是取消一致性。模型已经生成 Tool Call 之后,如果工具执行阶段被 Interrupt, 历史仍保留 Call,规范化逻辑可以为缺失结果补 aborted Output;若先执行、最后才记 Call,取消可能 留下无法解释的 Tool Result。

Codex 从基础指令解析、初始 developer 和 contextual user 上下文安装、用户消息规范化、普通与 Lite 请求编码,到 assistant 输出事件和历史回放的完整时序图
图 1.2-2:Base Instructions 每次采样显式附加;developer/user 作为类型化历史项进入 input;assistant 完成项先发事件并持久化,再参与下一次采样。

历史 assistant 文本仍是 assistant。它可以帮助模型延续计划、记住已解释的结果,但不会因为重新 进入 input 就升级成新的 developer policy。把旧 assistant 的“我已获得许可”当作授权证据,是把 对话连续性和权限状态混为一谈。

Commentary、FinalAnswer 与 Unknown 是三态

assistant Message 有可选 phase

phase语义Runtime 不能做的假设
commentaryTurn 中途说明、进度或前言不能据此结束 Turn;后面可能有工具或更多输出
final_answer当前 Turn 的终局回答文本仍需结合工具、输入队列和响应完成状态收尾
NoneProvider/旧模型没有标注不能丢弃,也不能强行改写成显式 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 Messagecontextual userdeveloper Messageassistant 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/回滚边界消失只认注册过的完整 MarkerMarker 不是身份认证
恢复历史含 system Message可能与当前基础指令冲突API 历史过滤 system;Session 重新解析自定义导入器仍需明确迁移规则
Lite/普通路径混淆基础指令看似缺失或重复按模型能力选择唯一编码分支抓包必须记录 Lite 能力
Prompt injection低层内容试图覆盖高层规则角色帮助模型识别冲突副作用仍必须经过确定性安全链

更严格的 role enum 可以提前拒绝未知值,却会降低协议演进兼容性;把所有内容退化为字符串列表最 简单,却会失去工具、Reasoning、多模态和上下文边界。Codex 选择的是“Item 类型严格、role 字段 开放、消费者显式校验”的中间方案。

这套设计应该怎样验证

验证角色不能只检查序列化 JSON,还要覆盖不同消费者的行为:

行为合同构造方式预期结果
system 不进入 API 历史记录 system、Reasoning、user、assistant 和 Othersystem/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 followingMap messages to Responses Items

评论


← 返回文章列表