跳转到内容

流式标识符

当模型并行发出多个 content block(最常见的是两个交错到达的 tool call)时,每个协议都需要一种稳定的方式把每条 delta 事件归属到正确的 in-flight block。三种支持的协议为此使用了不同的标识模型,这些差异解释了为什么 ProxAI 的流式翻译器要携带它们所携带的簿记字段。

下面的例子是同一个逻辑响应在三种协议中的表现:一段简短的前言文本,后面跟着两个参数 delta 交错到达的 tool call。

Anthropic 在每个 content_block_* 事件上带一个整数 index。该 index 是 block 在响应 content 数组中的位置,由上游按到达顺序赋值,仅在其产生的 SSE 流内部有意义。大多数 block 上没有稳定的跨请求字符串标识符。例外是 tool_use,它携带 id(如 toolu_abc),因为下一轮的 tool_result block 必须引用它。

event: content_block_start
data: {"index":0, "content_block":{"type":"text","text":""}}
event: content_block_start
data: {"index":1, "content_block":{"type":"tool_use","id":"toolu_a","name":"get_weather","input":{}}}
event: content_block_start
data: {"index":2, "content_block":{"type":"tool_use","id":"toolu_b","name":"get_time","input":{}}}
event: content_block_delta
data: {"index":0, "delta":{"type":"text_delta","text":"Looking up"}}
event: content_block_delta
data: {"index":1, "delta":{"type":"input_json_delta","partial_json":"{\"city\":"}}
event: content_block_delta
data: {"index":2, "delta":{"type":"input_json_delta","partial_json":"{\"tz\":"}}
event: content_block_delta
data: {"index":1, "delta":{"type":"input_json_delta","partial_json":"\"Beijing\"}"}}
event: content_block_delta
data: {"index":2, "delta":{"type":"input_json_delta","partial_json":"\"Asia/Shanghai\"}"}}
event: content_block_stop
data: {"index":0}
event: content_block_stop
data: {"index":1}
event: content_block_stop
data: {"index":2}

ProxAI 以 BTreeMap<u32, StreamBlock> 保存打开的 block。任何不一致(重复 start、孤儿 delta、未 start 就 stop、delta 的负载类型与已 start block 类型不匹配)都会变成 StreamTranslationError::Semantic

一个最简客户端通过以 index 为 key 拼装同一个流:

import json
from 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}),
]
@dataclass
class 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.added
data: {"sequence_number":2, "output_index":0,
"item":{"type":"message","id":"msg_1","status":"in_progress","content":[]}}
event: response.output_text.delta
data: {"sequence_number":3, "item_id":"msg_1", "output_index":0, "delta":"Looking up"}
event: response.output_item.added
data: {"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.added
data: {"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.delta
data: {"sequence_number":6, "item_id":"fc_a", "output_index":1, "delta":"{\"city\":"}
event: response.function_call_arguments.delta
data: {"sequence_number":7, "item_id":"fc_b", "output_index":2, "delta":"{\"tz\":"}
event: response.function_call_arguments.delta
data: {"sequence_number":8, "item_id":"fc_a", "output_index":1, "delta":"\"Beijing\"}"}
event: response.function_call_arguments.delta
data: {"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 json
from 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\"}"},
]
@dataclass
class 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 同时携带其 idname。后续参数 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 json
from 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 的首次出现必须同时携带其 idname。Text 和 reasoning delta 根本没有标识符——客户端只能按到达顺序 append,这正是为什么 Chat 在表达真正并行的 content block 时是最弱的。

标识符模型并非一一对应,因此跨协议转换必须填充缺失的那一侧:

转换方向上游给出目标要求ProxAI 做什么
Anthropic -> Responses (text/thinking)仅流内 index稳定字符串 item_idOutputItemIdAllocator 从 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_reasonstop_reasonusage 以及整个 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_reasonusagestatusincomplete_details 都是单个终态 response.completed / response.incomplete 事件的字段,该事件内嵌完整的最终 Response 对象。流是一系列 delta 渐进式构造一个 Response;终态事件提交它。

这是由 Responses wire model 强制的,不是翻译器选择。Anthropic 的 MessageDelta 事件正好携带那两块 Responses 没有独立事件承载的终态元数据(stop_reasonusage)。翻译器无处把它们作为独立更新 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,暂不 emit
MessageStreamEvent::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 事件(MessageStartContentBlockStartContentBlockDeltaContentBlockStop),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.doneresponse.output_item.done
`content_block_stop` (thinking)response.reasoning_text.doneresponse.output_item.done
`content_block_stop` (tool_use)response.function_call_arguments.doneresponse.output_item.done
`message_delta`(把 stop_reason + usage 写入 state)
`message_stop`response.completedresponse.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() 从它读取 identitystop_reasoninput_tokensoutput_tokens 来构建终态事件 payload。翻译器状态机看起来对称(Streaming -> ReceivedTerminalDelta -> Stopped),但每个 state 扮演的角色完全不同。

同样的 stateless-vs-snapshot 分裂也驱动着 per-block 簿记。两个翻译器都按 Anthropic content_block index 保存一个 in-flight StreamBlock,但两个 enum 携带非常不同的 payload:

to_openai_chat_completions/streaming.rs
enum StreamBlock {
Text,
ToolUse { chat_tool_index: u32 },
Thinking,
Ignored,
}
to_openai_responses/streaming.rs
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 最终必须为两个下游消费者产生一个完整的 OutputItemresponse.output_item.done(携带最终化的 item)和 response.completed 快照的 output 数组。Text 需要 text 给快照、item_id 给每个 delta/done 事件、citations 给 annotation 转换。ToolUse 需要累积的 arguments 加上 nameitem_id。RedactedThinking 没有流式 delta,但必须把其 data payload 表达为 encrypted_content

逐字段论证,按每个字段的消费者分类:

Block 类型字段Chat 翻译器Responses 翻译器备注
Textdiscriminantdelta 类型校验
item_idResponses 在每个 delta/done 事件上强制 item_id;Chat 没有 item-level id
累积的 textResponses 从它构建 output_text.done + 快照;Chat 立刻 emit delta
citationsResponses OutputTextContent.annotations;Chat 用不同方式处理 annotations
Thinkingdiscriminantdelta 类型校验
item_id / text与 Text 相同原因
RedactedThkitem_id / dataIgnoredResponses encrypted_content;Chat 协议没有 reasoning slot,流式与非流式都丢弃
ToolUsediscriminantdelta 类型校验
chat_tool_indexChat-only 整数 index 进入 tool_calls[];Responses 用 item_id
item_id / nameResponses 需要稳定 id + name 给 done 事件和快照
累积的 argsResponses 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.deltareasoning_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_callcode_interpreter_callmcp_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_idprevious_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 更容易遍历

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 这一侧。