文字 Delta 用来降低首字延迟,完整 Assistant Message 才是可记录事实。Codex 还在两者之间放了流式文本解析器,避免把 citation 标记、Plan 模式控制块或尚未判定的前缀直接展示。
具体问题与启用条件
本节只处理 Assistant 文本的暂态展示和最终提交;Reasoning 在 5.15,工具参数在 5.16—5.17。
| 条件来源 | 决定字段或状态 | 对本机制的影响 |
|---|---|---|
| 活动类型 | active_item 可投影为 AgentMessage | Text Delta 进入消息 parser |
| 扩展贡献者 | TurnItemContributor 非空 | 推迟流式 Started/Delta,防止最终改写不一致 |
| Plan 模式 | ModeKind::Plan | 解析 proposed plan 段而非普通显示 |
协议、类型与状态所有权
| 状态或协议 | 所有者 | 生命周期 | 关键不变量 |
|---|---|---|---|
| AssistantMessageStreamParsers | 一次采样 | 按 item_id 保存到 Done/Completed | 仅暂态,不写 History |
| AgentMessageContentDelta | Session event stream | 即时事件 | 携带 thread/turn/item ID |
| 完整 Message ResponseItem | 服务端 Done 事件 | History/Rollout | 最终文本是权威内容 |
| last_agent_message | 采样结果 | 当前 Turn | 来自完成项而非 Delta 拼接 |
机制调用链如下:
OutputItemAdded(Message)
→ 建立/seed parser 与 ItemStarted
→ OutputTextDelta
→ parser 按 item_id 处理 citation/plan/空白前缀
→ AgentMessageContentDelta
→ OutputItemDone(full Message)
→ flush parser 尾部
→ finalize TurnItem
→ History + ItemCompleted
机制怎样工作
Item Added 可能已带一段初始文本,parser 先 seed_item_text,确保后续 delta 不与它重复。每个 Text Delta 读取 active item ID;普通 AgentMessage 经过 parser,去掉不向用户展示的 citation 标记,并按 Plan 模式分割 proposed plan。解析器会缓存尚不能确定是否为控制标记的行和前导空白,所以 Done 与整个 Response Completed 都必须显式 flush。
最终完整 Message 不靠客户端 Delta 累加生成,而是来自 OutputItemDone 的服务端 ResponseItem。它再经过贡献者、citation 与 plan 隐藏标记清理,发 ItemCompleted 并立即记录到 History/Rollout。若存在 TurnItemContributor,流式展示被延迟,因为贡献者可能修改最终 TurnItem;即时显示旧版本会让 Started/Delta 与 Completed 内容不一致。
Python 风格伪代码
这段伪代码保留生产实现中会改变结果的状态、分支和异步边界;认证 SDK、遥测字段和 Rust 所有权样板被折叠为明确的领域对象。
class MessageStreamState:
def __init__(self, plan_mode: bool):
self.parsers: dict[str, AssistantTextParser] = {}
async def start(self, item: MessageItem, stream_to_client: bool):
parser = self.parsers.setdefault(item.id, AssistantTextParser())
visible_seed = parser.seed(item.raw_text)
if stream_to_client:
await emit_item_started(item.with_text(visible_seed))
async def delta(self, item_id: str, text: str, stream_to_client: bool):
if not stream_to_client:
return
parsed = self.parsers[item_id].push(text)
if parsed.visible_text:
await emit_content_delta(item_id, parsed.visible_text)
async def done(self, full_item: MessageItem):
tail = self.parsers.pop(full_item.id).finish()
if tail.visible_text:
await emit_content_delta(full_item.id, tail.visible_text)
finalized = strip_hidden_markup(full_item)
await emit_item_completed(finalized)
await history.record(full_item) # 记录协议完整项
失败、取消与恢复
| 故障或边界 | 已发生的状态 | 对上层的结果 | 能否直接重试 | 恢复动作 |
|---|---|---|---|---|
| Text Delta 无 active item | 无法给出 item_id | 内部错误 | 否 | 中止/诊断协议序列 |
| 流在 Item Done 前断开 | UI 可能见到暂态片段 | 不生成完整 Message | 是 | 重试时不把片段进 History |
| parser 尾部未 flush | 最后标记/文字仍缓存 | UI 缺尾部 | 不适用 | Done 与 Completed 双重 flush |
| Contributor 改写最终项 | 流式旧版本可能已展示 | 因此提前禁用 stream | 不适用 | 只展示最终 Item |
设计取舍与验证
服务端完整项作为权威来源避免客户端拼接差异和断流污染,代价是 Delta 与最终项要走两条路径。解析隐藏标记会增加一点展示延迟,却阻止半个 citation/plan tag 闪到 UI。对贡献者禁用流式输出牺牲 TTFT,换取扩展改写的一致性。
| 可验证契约 | 证据方式 | 预期结果 |
|---|---|---|
| Delta 共享同一 item_id | items 集成测试 | Started、所有 Delta、Completed ID 一致 |
| Review/Contributor 场景抑制 Delta | 专用事件断言 | 客户端只见最终项 |
| citation/plan 控制文本不显示 | parser 单元与模式测试 | 可见文字正确,原始项仍可记录 |
Mini Codex 对照
Mini Codex 可以先只处理普通文本,但仍应维护 item_id -> parser,并把 UI buffer 与 History 分开。隐藏标记 parser 可作为可替换策略,避免把产品语法焊死在事件循环。
本节边界
Assistant 文字的两阶段交付已说明。5.15 用相同活动项边界处理两套 Reasoning 流:摘要和原始内容。
评论
登录后即可评论