流式标识符
流式标识模型与并行拼装
Section titled “流式标识模型与并行拼装”当模型并行发出多个 content block(最常见的是两个交错到达的 tool call)时,每个协议都需要一种稳定的方式把每条 delta 事件归属到正确的 in-flight block。三种支持的协议为此使用了不同的标识模型,这些差异解释了为什么 ProxAI 的流式翻译器要携带它们所携带的簿记字段。
下面的例子是同一个逻辑响应在三种协议中的表现:一段简短的前言文本,后面跟着两个参数 delta 交错到达的 tool call。
Anthropic Messages:流内 index
Section titled “Anthropic Messages:流内 index”Anthropic 在每个 content_block_* 事件上带一个整数 index。该 index 是 block 在响应 content 数组中的位置,由上游按到达顺序赋值,仅在其产生的 SSE 流内部有意义。大多数 block 上没有稳定的跨请求字符串标识符。例外是 tool_use,它携带 id(如 toolu_abc),因为下一轮的 tool_result block 必须引用它。
event: content_block_startdata: {"index":0, "content_block":{"type":"text","text":""}}
event: content_block_startdata: {"index":1, "content_block":{"type":"tool_use","id":"toolu_a","name":"get_weather","input":{}}}
event: content_block_startdata: {"index":2, "content_block":{"type":"tool_use","id":"toolu_b","name":"get_time","input":{}}}
event: content_block_deltadata: {"index":0, "delta":{"type":"text_delta","text":"Looking up"}}
event: content_block_deltadata: {"index":1, "delta":{"type":"input_json_delta","partial_json":"{\"city\":"}}
event: content_block_deltadata: {"index":2, "delta":{"type":"input_json_delta","partial_json":"{\"tz\":"}}
event: content_block_deltadata: {"index":1, "delta":{"type":"input_json_delta","partial_json":"\"Beijing\"}"}}
event: content_block_deltadata: {"index":2, "delta":{"type":"input_json_delta","partial_json":"\"Asia/Shanghai\"}"}}
event: content_block_stopdata: {"index":0}event: content_block_stopdata: {"index":1}event: content_block_stopdata: {"index":2}ProxAI 以 BTreeMap<u32, StreamBlock> 保存打开的 block。任何不一致(重复 start、孤儿 delta、未 start 就 stop、delta 的负载类型与已 start block 类型不匹配)都会变成 StreamTranslationError::Semantic。
一个最简客户端通过以 index 为 key 拼装同一个流:
import jsonfrom dataclasses import dataclass, field
events = [ ("content_block_start", {"index": 0, "content_block": {"type": "text", "text": ""}}), ("content_block_start", {"index": 1, "content_block": {"type": "tool_use", "id": "toolu_a", "name": "get_weather", "input": {}}}), ("content_block_start", {"index": 2, "content_block": {"type": "tool_use", "id": "toolu_b", "name": "get_time", "input": {}}}), ("content_block_delta", {"index": 0, "delta": {"type": "text_delta", "text": "Looking up"}}), ("content_block_delta", {"index": 1, "delta": {"type": "input_json_delta", "partial_json": "{\"city\":"}}), ("content_block_delta", {"index": 2, "delta": {"type": "input_json_delta", "partial_json": "{\"tz\":"}}), ("content_block_delta", {"index": 1, "delta": {"type": "input_json_delta", "partial_json": "\"Beijing\"}"}}), ("content_block_delta", {"index": 2, "delta": {"type": "input_json_delta", "partial_json": "\"Asia/Shanghai\"}"}}), ("content_block_stop", {"index": 0}), ("content_block_stop", {"index": 1}), ("content_block_stop", {"index": 2}),]
@dataclassclass Block: type: str text: str = "" id: str | None = None name: str | None = None arguments: str = ""
open_blocks: dict[int, Block] = {}finished: list[Block] = []
for event_type, data in events: idx = data["index"] if event_type == "content_block_start": cb = data["content_block"] open_blocks[idx] = Block(type=cb["type"], id=cb.get("id"), name=cb.get("name")) elif event_type == "content_block_delta": delta = data["delta"] block = open_blocks[idx] if delta["type"] == "text_delta": block.text += delta["text"] elif delta["type"] == "input_json_delta": block.arguments += delta["partial_json"] elif event_type == "content_block_stop": finished.append(open_blocks.pop(idx))
for b in finished: if b.type == "text": print(f"TEXT: {b.text!r}") elif b.type == "tool_use": print(f"TOOL_CALL: name={b.name!r} arguments={b.arguments}")关键点:index 字段是唯一的 join key。block 的任何信息都不会存活到产生它的流之外——未来的 messages 轮次必须重复整个 content 数组。
OpenAI Responses:全局 item_id + 事件 sequence_number
Section titled “OpenAI Responses:全局 item_id + 事件 sequence_number”Responses 事件携带每个事件的 monotonic sequence_number: u64(让客户端检测丢事件或乱序)以及一个 per-item 字符串 item_id,它在跨请求、跨快照、跨 previous_response_id 链中都稳定。output_index: u32 也存在,但只是便利定位器,不是主标识符。
event: response.output_item.addeddata: {"sequence_number":2, "output_index":0, "item":{"type":"message","id":"msg_1","status":"in_progress","content":[]}}
event: response.output_text.deltadata: {"sequence_number":3, "item_id":"msg_1", "output_index":0, "delta":"Looking up"}
event: response.output_item.addeddata: {"sequence_number":4, "output_index":1, "item":{"type":"function_call","id":"fc_a","call_id":"call_a", "name":"get_weather","arguments":""}}
event: response.output_item.addeddata: {"sequence_number":5, "output_index":2, "item":{"type":"function_call","id":"fc_b","call_id":"call_b", "name":"get_time","arguments":""}}
event: response.function_call_arguments.deltadata: {"sequence_number":6, "item_id":"fc_a", "output_index":1, "delta":"{\"city\":"}
event: response.function_call_arguments.deltadata: {"sequence_number":7, "item_id":"fc_b", "output_index":2, "delta":"{\"tz\":"}
event: response.function_call_arguments.deltadata: {"sequence_number":8, "item_id":"fc_a", "output_index":1, "delta":"\"Beijing\"}"}
event: response.function_call_arguments.deltadata: {"sequence_number":9, "item_id":"fc_b", "output_index":2, "delta":"\"Asia/Shanghai\"}"}Responses 客户端只靠 item_id 就能把 delta 关联回 item;sequence_number 是独立的排序元数据,不是 item 身份的一部分。
一个最简客户端通过以 item_id 为 key、sequence_number 仅用于 sanity check 来拼装同一个流:
import jsonfrom dataclasses import dataclass, field
events = [ {"sequence_number": 2, "type": "response.output_item.added", "output_index": 0, "item": {"type": "message", "id": "msg_1", "status": "in_progress", "content": []}}, {"sequence_number": 3, "type": "response.output_text.delta", "item_id": "msg_1", "output_index": 0, "delta": "Looking up"}, {"sequence_number": 4, "type": "response.output_item.added", "output_index": 1, "item": {"type": "function_call", "id": "fc_a", "call_id": "call_a", "name": "get_weather", "arguments": ""}}, {"sequence_number": 5, "type": "response.output_item.added", "output_index": 2, "item": {"type": "function_call", "id": "fc_b", "call_id": "call_b", "name": "get_time", "arguments": ""}}, {"sequence_number": 6, "type": "response.function_call_arguments.delta", "item_id": "fc_a", "output_index": 1, "delta": "{\"city\":"}, {"sequence_number": 7, "type": "response.function_call_arguments.delta", "item_id": "fc_b", "output_index": 2, "delta": "{\"tz\":"}, {"sequence_number": 8, "type": "response.function_call_arguments.delta", "item_id": "fc_a", "output_index": 1, "delta": "\"Beijing\"}"}, {"sequence_number": 9, "type": "response.function_call_arguments.delta", "item_id": "fc_b", "output_index": 2, "delta": "\"Asia/Shanghai\"}"},]
@dataclassclass Item: type: str id: str text: str = "" name: str | None = None arguments: str = ""
items: dict[str, Item] = {}expected_seq = None
for ev in events: if expected_seq is not None and ev["sequence_number"] != expected_seq: print(f"warning: sequence gap, expected {expected_seq}, got {ev['sequence_number']}") expected_seq = ev["sequence_number"] + 1
if ev["type"] == "response.output_item.added": item = ev["item"] items[item["id"]] = Item(type=item["type"], id=item["id"], name=item.get("name")) elif ev["type"] == "response.output_text.delta": items[ev["item_id"]].text += ev["delta"] elif ev["type"] == "response.function_call_arguments.delta": items[ev["item_id"]].arguments += ev["delta"]
for item in items.values(): if item.type == "message": print(f"TEXT: {item.text!r}") elif item.type == "function_call": print(f"TOOL_CALL: name={item.name!r} arguments={item.arguments}")关键点:join 是按 item_id,不是按到达顺序,所以并行的 tool call 的交错 delta 不需要额外簿记就能落到正确的 item。同一个 item_id 也会出现在未来的 response.completed 快照里,或出现在使用 previous_response_id 的后续请求中。
OpenAI Chat Completions:per-chunk 整数 index
Section titled “OpenAI Chat Completions:per-chunk 整数 index”Chat Completions 给每个 tool call 在每个流式 chunk 的 tool_calls 数组里分配一个整数 index。没有单独的 “item added” 事件;tool call 的首个 chunk 同时携带其 id 和 name。后续参数 delta 复用同一个整数 index 定位同一个调用。
data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"Looking up"}}]}
data: {"choices":[{"index":0,"delta":{"tool_calls":[ {"index":0,"id":"call_a","type":"function", "function":{"name":"get_weather","arguments":""}}]}}]}
data: {"choices":[{"index":0,"delta":{"tool_calls":[ {"index":1,"id":"call_b","type":"function", "function":{"name":"get_time","arguments":""}}]}}]}
data: {"choices":[{"index":0,"delta":{"tool_calls":[ {"index":0,"function":{"arguments":"{\"city\":"}}]}}]}
data: {"choices":[{"index":0,"delta":{"tool_calls":[ {"index":1,"function":{"arguments":"{\"tz\":"}}]}}]}
data: {"choices":[{"index":0,"delta":{"tool_calls":[ {"index":0,"function":{"arguments":"\"Beijing\"}"}}]}}]}
data: {"choices":[{"index":0,"delta":{"tool_calls":[ {"index":1,"function":{"arguments":"\"Asia/Shanghai\"}"}}]}}]}Chat 的 index 作用域限于一个流的 tool-call 数组,精神上与 Anthropic 的流内 index 类似,但只对 tool call 生效——Chat 根本没有针对 text 或 reasoning delta 的流级标识符,因为这些 chunk 除了到达顺序之外没有任何关联方式。
一个最简客户端通过以内部的 tool_calls[].index 为 key 关联 tool-call delta、并把 text delta 视为纯 append-only 来拼装同一个流:
import jsonfrom dataclasses import dataclass
chunks = [ {"choices": [{"index": 0, "delta": {"role": "assistant", "content": "Looking up"}}]}, {"choices": [{"index": 0, "delta": {"tool_calls": [ {"index": 0, "id": "call_a", "type": "function", "function": {"name": "get_weather", "arguments": ""}} ]}}]}, {"choices": [{"index": 0, "delta": {"tool_calls": [ {"index": 1, "id": "call_b", "type": "function", "function": {"name": "get_time", "arguments": ""}} ]}}]}, {"choices": [{"index": 0, "delta": {"tool_calls": [ {"index": 0, "function": {"arguments": "{\"city\":"}} ]}}]}, {"choices": [{"index": 0, "delta": {"tool_calls": [ {"index": 1, "function": {"arguments": "{\"tz\":"}} ]}}]}, {"choices": [{"index": 0, "delta": {"tool_calls": [ {"index": 0, "function": {"arguments": "\"Beijing\"}"}} ]}}]}, {"choices": [{"index": 0, "delta": {"tool_calls": [ {"index": 1, "function": {"arguments": "\"Asia/Shanghai\"}"}} ]}}]},]
text_parts: list[str] = []tool_calls: dict[int, dict] = {}
for chunk in chunks: delta = chunk["choices"][0]["delta"] if "content" in delta and delta["content"] is not None: text_parts.append(delta["content"]) for tc in delta.get("tool_calls", []): slot = tool_calls.setdefault(tc["index"], {"name": None, "arguments": ""}) fn = tc.get("function", {}) if "name" in fn: slot["name"] = fn["name"] if "arguments" in fn: slot["arguments"] += fn["arguments"]
print(f"TEXT: {''.join(text_parts)!r}")for slot in tool_calls.values(): print(f"TOOL_CALL: name={slot['name']!r} arguments={slot['arguments']}")关键点:Chat Completions 没有等价于 output_item.added 的事件,所以 tool_calls[].index 的首次出现必须同时携带其 id 和 name。Text 和 reasoning delta 根本没有标识符——客户端只能按到达顺序 append,这正是为什么 Chat 在表达真正并行的 content block 时是最弱的。
为什么翻译器必须合成标识符
Section titled “为什么翻译器必须合成标识符”标识符模型并非一一对应,因此跨协议转换必须填充缺失的那一侧:
| 转换方向 | 上游给出 | 目标要求 | ProxAI 做什么 |
|---|---|---|---|
| Anthropic -> Responses (text/thinking) | 仅流内 index | 稳定字符串 item_id | OutputItemIdAllocator 从 response id 生成 item id |
| Anthropic -> Responses (tool_use) | tool_use.id (toolu_*) | 字符串 item_id | 直接透传 |
| Anthropic -> Chat (任意 tool call) | tool_use.id + 流内 index | 与上游 index 无关的流内整数 tool_calls[].index | 翻译器维护 next_tool_call_index,并按 block 记住映射 |
| Responses -> 其它 | item_id(已是稳定字符串) | 流内 index 或流内 id | 从 output 位置派生或直接透传 |
这就解释了为什么两个流式翻译器持有不同的簿记状态:to_openai_responses/streaming.rs 为 text 和 reasoning block 携带 OutputItemIdAllocator,而 to_openai_chat_completions/streaming.rs 为 tool call 携带 next_tool_call_index。两者都不是装饰性的——每个都填补了目标协议要求而上游协议不提供的真实标识符缺口。
事件粒度:stateless 与 snapshot-bound
Section titled “事件粒度:stateless 与 snapshot-bound”标识符差异只是 Chat Completions 与 Responses 如何建模流式的一个更深分裂的症状。把它拆开讲能让其余翻译器结构变得直观。
两个协议都按到达顺序流式 emit 增量 content delta。 Text、tool-call 参数片段、reasoning text 在两种转换中都是逐 chunk emit 的;关于 “snapshot vs stateless” 的区分不影响这些。
分裂只在终态元数据上:finish_reason、stop_reason、usage 以及整个 response 的最终状态。
Chat Completions 对终态元数据是 event-oriented 的。 它为每一段都有专用的、自包含的 chunk shape:
choices[].finish_reason在一个专用 chunk 上携带 stop reason- 一个
choices: []chunk 作为独立更新携带usage [DONE]是裸的流终止符
每个 chunk 都自包含:一旦 emit,翻译器再也不需要它的 payload。Chat 没有 “final response snapshot” 概念——一旦 finish_reason chunk 发出,就没有第二次修订机会。
Responses 对终态元数据是 snapshot-oriented 的。 没有独立的 finish_reason 事件,没有独立的 usage 事件。相反,stop_reason、usage、status、incomplete_details 都是单个终态 response.completed / response.incomplete 事件的字段,该事件内嵌完整的最终 Response 对象。流是一系列 delta 渐进式构造一个 Response;终态事件提交它。
这是由 Responses wire model 强制的,不是翻译器选择。Anthropic 的 MessageDelta 事件正好携带那两块 Responses 没有独立事件承载的终态元数据(stop_reason 和 usage)。翻译器无处把它们作为独立更新 emit——它们只能作为最终快照的字段存在。所以在 MessageDelta 期间翻译器把它们写入 state,什么也不 emit。
具体地,两个翻译器走同一个 Anthropic MessageDelta 事件,但做的事几乎相反:
// Chat:现在全部 emit,然后等 MessageStop 只为发送 [DONE]MessageStreamEvent::MessageDelta(event) => { let mut state = self.take_streaming_state()?; ... chunks.push(chat_finish_chunk(&identity, finish_reason)); // emit chunks.push(chat_usage_chunk(&identity, event.usage.into())); // emit self.lifecycle = StreamLifecycle::ReceivedTerminalDelta(state); // state 后面不再使用}MessageStreamEvent::MessageStop(_) => { let _state = self.take_terminal_state()?; chunks.push(ChatStreamOutput::DoneSentinel); // emit [DONE]}// Responses:把字段写进 state,暂不 emitMessageStreamEvent::MessageDelta(event) => { let mut state = self.take_streaming_state()?; state.input_tokens = ...; // 累积 state.output_tokens = ...; // 累积 state.stop_reason = Some(stop_reason);// 累积 self.lifecycle = StreamLifecycle::ReceivedTerminalDelta(state);}MessageStreamEvent::MessageStop(_) => { let state = self.take_terminal_state()?; let status = state.terminal_response_status(); // 读取累积 let response = state.response_snapshot(status); // 读取累积 chunks.push(terminal_response_event(status, seq, response)); // 一次 emit}注意,对其他每个 Anthropic 事件(MessageStart、ContentBlockStart、ContentBlockDelta、ContentBlockStop),Responses 翻译器都会立刻 emit 对应的 Responses 事件。只有 MessageDelta 是沉默的,因为只有 MessageDelta 的 payload 没有可映射的独立 Responses 事件。完整的 Anthropic -> Responses 事件映射是:
| Anthropic 事件 | emit 的 Responses 事件 |
|---|---|
| `message_start` | response.created(带 in-progress 快照) |
| `content_block_start` (text) | response.output_item.added (message) |
| `content_block_start` (thinking) | (仅 register;首个 delta emit reasoning_text.delta) |
| `content_block_start` (tool_use) | response.output_item.added (function_call) |
| `content_block_delta` (text_delta) | response.output_text.delta |
| `content_block_delta` (thinking_delta) | response.reasoning_text.delta |
| `content_block_delta` (input_json_delta) | response.function_call_arguments.delta |
| `content_block_stop` (text) | response.output_text.done、response.output_item.done |
| `content_block_stop` (thinking) | response.reasoning_text.done、response.output_item.done |
| `content_block_stop` (tool_use) | response.function_call_arguments.done、response.output_item.done |
| `message_delta` | 无(把 stop_reason + usage 写入 state) |
| `message_stop` | response.completed 或 response.incomplete(带最终快照) |
还有一个协议安全角度:Responses 客户端把 response.completed 视为不可逆终态。在 MessageStop 之前 emit 它意味着客户端认为 response 已完成,而上游 SSE 流可能还会产生事件。把快照 emit 与 MessageStop 对齐,可让 “流结束” 与 “response 完成” 同步,这正是客户端期望的契约。
这解释了两个翻译器之间 StreamingState 字段的差异:
| `StreamingState` 字段 | 在 Chat? | 在 Responses? | 原因 |
|---|---|---|---|
| `identity` | 是 | 是 | 两个协议在每个 chunk 上都 echo 它 |
| `output` (representable tracker) | 是 | 是 | 两者都需要检测空流 |
| `blocks` | 是 | 是 | 两者都按 index 关联 Anthropic block delta |
| `next_tool_call_index` | 是 | 否 | 仅 Chat 分配整数 tool-call index |
| `item_ids: OutputItemIdAllocator` | 否 | 是 | 仅 Responses 要求稳定的字符串 item id |
| `stop_reason` | 否 | 是 | Chat 立刻 emit finish_reason;Responses 从 state 读取给终态快照 |
| `input_tokens` / `output_tokens` | 否 | 是 | Chat 立刻 emit usage chunk;Responses 从 state 读取给终态快照 |
实际后果:在 Chat 中,ReceivedTerminalDelta 期间持有的 state 实际上是死重量——take_terminal_state() 返回它,调用者立刻丢弃。在 Responses 中,那个 state 才是全部意义所在——response_snapshot() 从它读取 identity、stop_reason、input_tokens、output_tokens 来构建终态事件 payload。翻译器状态机看起来对称(Streaming -> ReceivedTerminalDelta -> Stopped),但每个 state 扮演的角色完全不同。
Per-block state 反映协议分裂
Section titled “Per-block state 反映协议分裂”同样的 stateless-vs-snapshot 分裂也驱动着 per-block 簿记。两个翻译器都按 Anthropic content_block index 保存一个 in-flight StreamBlock,但两个 enum 携带非常不同的 payload:
enum StreamBlock { Text, ToolUse { chat_tool_index: u32 }, Thinking, Ignored,}enum StreamBlock { Text { item_id: String, text: String, citations: Option<Vec<TextCitation>> }, Thinking { item_id: String, text: String }, RedactedThinking { item_id: String, data: String }, ToolUse { item_id: String, name: String, arguments: String },}Chat 只区分 block 类型,加上一个整数(chat_tool_index),它必须在每个 tool_calls[].index chunk 上回填。实际内容——text 片段、tool 参数、reasoning text——逐 chunk emit,不需要保留。Chat 也没有地方表示 redacted thinking 或 per-item reasoning,所以这些 block 塌缩成 Ignored。
Responses 累积内容,因为每个 block 最终必须为两个下游消费者产生一个完整的 OutputItem:response.output_item.done(携带最终化的 item)和 response.completed 快照的 output 数组。Text 需要 text 给快照、item_id 给每个 delta/done 事件、citations 给 annotation 转换。ToolUse 需要累积的 arguments 加上 name 和 item_id。RedactedThinking 没有流式 delta,但必须把其 data payload 表达为 encrypted_content。
逐字段论证,按每个字段的消费者分类:
| Block 类型 | 字段 | Chat 翻译器 | Responses 翻译器 | 备注 |
|---|---|---|---|---|
| Text | discriminant | 是 | 是 | delta 类型校验 |
item_id | 否 | 是 | Responses 在每个 delta/done 事件上强制 item_id;Chat 没有 item-level id | |
累积的 text | 否 | 是 | Responses 从它构建 output_text.done + 快照;Chat 立刻 emit delta | |
citations | 否 | 是 | Responses OutputTextContent.annotations;Chat 用不同方式处理 annotations | |
| Thinking | discriminant | 是 | 是 | delta 类型校验 |
item_id / text | 否 | 是 | 与 Text 相同原因 | |
| RedactedThk | item_id / data | Ignored | 是 | Responses encrypted_content;Chat 协议没有 reasoning slot,流式与非流式都丢弃 |
| ToolUse | discriminant | 是 | 是 | delta 类型校验 |
chat_tool_index | 是 | 否 | Chat-only 整数 index 进入 tool_calls[];Responses 用 item_id | |
item_id / name | 否 | 是 | Responses 需要稳定 id + name 给 done 事件和快照 | |
累积的 args | 否 | 是 | Responses function_call_arguments.done + 快照 |
两边每个字段都至少有一个真实消费者;没有死重量,也没有缺失它本来会用的字段。统一两个 enum 要么给 Chat 加未用字段,要么饿死 Responses 需要的数据。这种不对称是协议分裂的显现,不是设计缺陷。
概念层级:扁平 content 与 item化 output
Section titled “概念层级:扁平 content 与 item化 output”标识符与事件粒度的分裂都源自一个更深层的结构差异:两个协议家族如何在一轮对话内部层级化内容。
Anthropic Messages 是扁平的。一条 message 携带一个 content[] 数组,每个 block——text、thinking、tool_use、tool_result、image——都是数组里平等的元素。没有内部嵌套:一个 TextBlock 就是 { text, citations },一个 ThinkingBlock 就是 { thinking, signature }。定位一个流式 delta 只需一个 index,即 block 在 content[] 中的位置。
OpenAI Responses 是item化的。一个 response 携带一个 output[] 数组装着 items,许多 item 类型本身内部又携带一个 content[] 数组装着 parts。message item 包含 OutputText / OutputImage / OutputAudio parts;reasoning item 包含 ReasoningText parts;function_call item 没有 content 数组,只有 arguments。定位一个流式 delta 需要两个 index:output_index(哪个 item)和 content_index(item 内部的哪个 part)。
Anthropic (1 层) Responses (2 层)───────────── ──────────────────────────────content[] output[]├─ [0] text ──────────────► ├─ [0] message├─ [1] thinking ──────────────► │ └─ content[]├─ [2] tool_use ──────────────► │ └─ [0] output_text└─ [3] tool_result ───────────► ├─ [1] reasoning │ └─ content[] │ └─ [0] reasoning_text ├─ [2] function_call (无 content[]) └─ [3] function_call_output (无 content[])这就是为什么 Anthropic -> Responses 翻译器在每一个 output_text.delta 和 reasoning_text.delta 事件中硬编码 content_index: 0。每个 Anthropic block 1:1 映射到一个 Responses item,且仅含一个 content part,所以没有第二个 index 可变。如果 Anthropic 将某天引入一个映射到单个 Responses item 内多个 part 的 block shape,翻译器就需要跟踪并 emit 真实的 content_index;在那之前,0 是正确的,不是占位符。
为什么有这种分裂:对话协议 vs 资源协议
Section titled “为什么有这种分裂:对话协议 vs 资源协议”这种层级选择并非随意——它反映了不同的设计意图。
Anthropic 把 message 当作一轮对话的原子单位。message 内部装什么(text、thinking、tool call)是 message 的私有事务;协议只承诺为整条 message 提供稳定的 message.id。跨轮需要重传整个 content 数组。这是邮件模型:每封 message 是一个不透明的信封,你引用信封,而不是信封里的一段话。它简单、线性,且匹配 LLM 流式的真实流向(token 接 token,block 接 block)。
Responses 把 response 当作资源容器,每个 item 当作有自己稳定 id 的、可独立寻址的子资源。item 可以跨请求被引用(previous_response_id 链)、被客户端 diff/patch/replay,并且——关键地——作为 hosted tools(web_search_call、code_interpreter_call、mcp_call、image generation)的锚点,它们的状态与生命周期属于 item 层,不应埋在 message 里。这是文件系统模型:每个文件有 inode,操作引用文件,而不是文件里的字节范围。
Id 分配策略由此衍生:
- Anthropic 警备性地分配 id——仅当实体必须跨轮边界被引用时。
tool_use.id存在是因为下一轮的tool_result.tool_use_id必须引用它。Text 和 thinking block 没有 id;如果需要引用一个,就重传整个数组。 - Responses 普遍地分配 id——每个 item 都有一个,因为每个 item 都是潜在的引用目标。function call 的
call_id、每个 delta 上 echo 的item_id、previous_response_id链——所有这些都默认 item 级别可寻址是规则而不是例外。
两种选择都不是严格更好。它们为不同的负载优化:
| 负载 | 更适合 |
|---|---|
| 单轮 text / 工具对话 | Anthropic——扁平更简单,流式是线性的 |
| 同一 item 内多模态 part | 平手——两者都能表达(Anthropic 通过 block 类型,Responses 通过 part 类型) |
| patch / diff 单个内容片段 | Responses——item id 使片段可寻址 |
| Hosted tools(image gen / code interpreter / MCP) | Responses——item 是天然的生命周期容器 |
| 流式增量输出 | Anthropic——一个 index,无内部嵌套 |
| 跨请求状态恢复 | Responses——稳定 item id 跨调用存活 |
| 客户端 / 翻译器实现复杂度 | Anthropic——扁平 content 更容易遍历 |
ProxAI 的翻译代价
Section titled “ProxAI 的翻译代价”Anthropic -> Responses 流式翻译器中的多数 bug 和结构复杂度都源于把扁平 content[] 抬升为 item 化的 output[]。翻译器需要:
- 为每个 text 和 reasoning block 生成稳定
item_id(Anthropic 不提供)——见OutputItemIdAllocator; - 维护一个独立于 Anthropic block index 的
output_index计数器; - emit
output_item.added/output_item.done对来建模目标协议要求的 item 生命周期; - 把已完成的 item 累积到
output_items向量,让最终response.completed快照能携带完整 output 数组; - 硬编码
content_index: 0,因为源协议没有“一个 block 内多个 part”的概念。
反向(Responses -> Anthropic)机械上更简单——把 output[] 拍扁回单个 content[] 会丢 item id 但保留语义。这种不对称也是为什么本次审计发现的大部分流式 bug 都位于 Anthropic -> Responses 这一侧。