Anthropic 完整交互示例
Anthropic 完整交互示例
Section titled “Anthropic 完整交互示例”完整交互示例
Section titled “完整交互示例”下面用一个简化场景串起客户端、本地工具、服务端工具和 SSE。
用户问:“北京今天适合跑步吗?如果空气质量不好,请查一下官方建议。”
客户端提供一个本地工具 get_weather,由客户端自己执行;同时允许上游使用服务端 web_search。从协议角度看,get_weather 会产生 tool_use,结果由客户端下一轮回填;web_search 会产生 server_tool_use 和对应的 server-tool result block,上游自己完成。
这个示例用于解释协议交互,不是可直接 replay 的测试 fixture。真实上游可能调整具体 server-tool result 内容、事件拆分粒度和 token usage 数值;proxai 当前 Anthropic 路径也不会重建这些事件,只透传上游 bytes。
Client | | 1. messages + custom get_weather + server web_search + stream=true vproxai | | 2. 原样转发 anthropic_messages vAnthropic upstream | | 3. SSE: server_tool_use(web_search) + server result + tool_use(get_weather) vproxai | | 4. 原始 SSE bytes 透传 vClient | | 5. 本地执行 get_weather vLocal tool runtime | | 6. 下一轮 messages 带 tool_result vproxai -> Anthropic upstream -> proxai -> Client请求里同时声明两类工具:
custom工具get_weather:本地工具,模型只能请求调用。web_search_20250305:服务端工具,上游可以自己执行并返回结果。
{ "model": "claude-sonnet-4-5", "max_tokens": 1024, "stream": true, "system": "你是一个简洁的出行建议助手。", "messages": [ { "role": "user", "content": "北京今天适合跑步吗?如果空气质量不好,请查一下官方建议。" } ], "tools": [ { "type": "custom", "name": "get_weather", "description": "查询指定城市的天气和空气质量摘要。", "input_schema": { "type": "object", "properties": { "city": { "type": "string" }, "date": { "type": "string" } }, "required": ["city", "date"] } }, { "type": "web_search_20250305", "name": "web_search", "max_uses": 1, "allowed_domains": ["www.cma.gov.cn", "www.mee.gov.cn"] } ], "tool_choice": { "type": "auto" }}对应到本地结构:
- 整个请求是
MessageCreateParamsBase。 tools[0]是ToolUnion::Custom(Tool)。tools[1]是ToolUnion::WebSearchTool20250305(...)。tool_choice是ToolChoice::Auto(...)。
更完整的结构映射如下。这里是字段映射伪代码,json! 表示 serde_json::json!,... 表示其余 Option 字段按需为 None:
MessageCreateParamsBase { model: "claude-sonnet-4-5".to_string(), max_tokens: 1024, stream: Some(true), system: Some(SystemPrompt::Text( "你是一个简洁的出行建议助手。".to_string(), )), messages: vec![MessageParam { role: Role::User, content: MessageParamContent::Text( "北京今天适合跑步吗?如果空气质量不好,请查一下官方建议。".to_string(), ), }], tools: Some(vec![ ToolUnion::Custom(Tool { name: "get_weather".to_string(), description: Some("查询指定城市的天气和空气质量摘要。".to_string()), input_schema: InputSchema { type_: "object".to_string(), properties: Some(json!({ "city": { "type": "string" }, "date": { "type": "string" } })), required: Some(vec!["city".to_string(), "date".to_string()]), extra: json!({}), }, type_: Some("custom".to_string()), ... }), ToolUnion::WebSearchTool20250305(WebSearchTool20250305 { name: "web_search".to_string(), type_: "web_search_20250305".to_string(), max_uses: Some(1), allowed_domains: Some(vec![ "www.cma.gov.cn".to_string(), "www.mee.gov.cn".to_string(), ]), ... }), ]), tool_choice: Some(ToolChoice::Auto(ToolChoiceAuto { ... })), // 其他可选字段省略为 None。 ...}第一轮 SSE
Section titled “第一轮 SSE”服务端工具调用和本地工具调用都出现在同一条 SSE 流中,但语义不同。
event: message_startdata: { "type": "message_start", "message": { "id": "msg_01", "type": "message", "role": "assistant", "content": [], "model": "claude-sonnet-4-5", "usage": { "input_tokens": 120, "output_tokens": 1 } }}
event: content_block_startdata: { "type": "content_block_start", "index": 0, "content_block": { "type": "server_tool_use", "id": "srvu_01", "name": "web_search", "caller": { "type": "direct" }, "input": { "query": "北京 空气质量 跑步 官方 建议" } }}
event: content_block_stopdata: { "type": "content_block_stop", "index": 0}
event: content_block_startdata: { "type": "content_block_start", "index": 1, "content_block": { "type": "web_search_tool_result", "id": "srvr_01", "caller": { "type": "direct" }, "input": { "query": "北京 空气质量 跑步 官方 建议" }, "name": "web_search", "content": [ { "type": "web_search_result", "title": "官方空气质量与健康建议", "url": "https://www.mee.gov.cn/example", "encrypted_content": "...", "page_age": null } ], "error": null }}
event: content_block_stopdata: { "type": "content_block_stop", "index": 1}
event: content_block_startdata: { "type": "content_block_start", "index": 2, "content_block": { "type": "tool_use", "id": "toolu_01", "name": "get_weather", "caller": { "type": "direct" }, "input": {} }}
event: content_block_deltadata: { "type": "content_block_delta", "index": 2, "delta": { "type": "input_json_delta", "partial_json": "{\"city\":\"北京\"" }}
event: content_block_deltadata: { "type": "content_block_delta", "index": 2, "delta": { "type": "input_json_delta", "partial_json": ",\"date\":\"today\"}" }}
event: content_block_stopdata: { "type": "content_block_stop", "index": 2}
event: message_deltadata: { "type": "message_delta", "delta": { "stop_reason": "tool_use", "stop_sequence": null }, "usage": { "output_tokens": 72, "server_tool_use": { "web_search_requests": 1, "web_fetch_requests": 0 } }}
event: message_stopdata: { "type": "message_stop"}逐段对应到本地结构:
MessageStreamEvent::MessageStart(MessageStartEvent { message: Message { id: "msg_01".to_string(), type_: MessageType::Message, role: Role::Assistant, content: vec![], model: "claude-sonnet-4-5".to_string(), usage: Usage { input_tokens: 120, output_tokens: 1, ... }, ... },})MessageStreamEvent::ContentBlockStart(ContentBlockStartEvent { index: 0, content_block: ContentBlock::ServerToolUse(ServerToolUseBlock { id: "srvu_01".to_string(), caller: ToolCaller::Direct(DirectCaller), name: ServerToolName::WebSearch, input: json!({ "query": "北京 空气质量 跑步 官方 建议" }), }),})
MessageStreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 0 })MessageStreamEvent::ContentBlockStart(ContentBlockStartEvent { index: 1, content_block: ContentBlock::WebSearchToolResult(ServerToolResultBlock { id: "srvr_01".to_string(), caller: ToolCaller::Direct(DirectCaller), input: json!({ "query": "北京 空气质量 跑步 官方 建议" }), name: ServerToolName::WebSearch, content: ServerToolResultContent::Data(json!([ { "type": "web_search_result", "title": "官方空气质量与健康建议", "url": "https://www.mee.gov.cn/example", "encrypted_content": "...", "page_age": null } ])), type_: "web_search_tool_result".to_string(), ... }),})
MessageStreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 1 })MessageStreamEvent::ContentBlockStart(ContentBlockStartEvent { index: 2, content_block: ContentBlock::ToolUse(ToolUseBlock { id: "toolu_01".to_string(), caller: ToolCaller::Direct(DirectCaller), name: "get_weather".to_string(), input: json!({}), }),})
MessageStreamEvent::ContentBlockDelta(ContentBlockDeltaEvent { index: 2, delta: ContentBlockDelta::InputJsonDelta(InputJsonDelta { partial_json: "{\"city\":\"北京\"".to_string(), }),})
MessageStreamEvent::ContentBlockDelta(ContentBlockDeltaEvent { index: 2, delta: ContentBlockDelta::InputJsonDelta(InputJsonDelta { partial_json: ",\"date\":\"today\"}".to_string(), }),})
MessageStreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 2 })MessageStreamEvent::MessageDelta(MessageDeltaEvent { delta: MessageDelta { stop_reason: Some(StopReason::ToolUse), ... }, usage: MessageDeltaUsage { output_tokens: 72, server_tool_use: Some(ServerToolUsage { web_search_requests: 1, web_fetch_requests: 0, }), ... },})
MessageStreamEvent::MessageStop(MessageStopEvent)这里的 MessageDelta 是 message 级别的状态增量,不是文本内容增量:
ContentBlockDelta更新某个content_block的内容,例如 text delta 或 tool input 的 partial JSON。MessageDelta更新整条 assistant message 的元数据,例如stop_reason、stop_sequence、container、stop_details。MessageDeltaEvent.usage携带到这一刻为止的 token/server-tool usage 增量或汇总。
在这个例子里,stop_reason: ToolUse 表示模型已经产出一个本地工具调用,并主动停下来等待客户端回填 tool_result。server_tool_use 则说明同一轮里上游已经执行过 1 次服务端 web search。
这段流对应的图示:
SSE content blocks
index=0 server_tool_use(web_search) id=srvu_01 | vindex=1 web_search_tool_result id=srvr_01 caller=direct
index=2 tool_use(get_weather) id=toolu_01 | v Client must run local tool关键点:
server_tool_use由上游服务执行,客户端不需要回填它的结果。web_search_tool_result在当前 wire model 中落到WebSearchToolResultBlock,其中content是WebSearchToolResultBlockContent,可以是错误或搜索结果数组。tool_use.id = toolu_01是本地工具调用 ID,客户端必须执行get_weather,并在下一轮用tool_result.tool_use_id = toolu_01回填。input_json_delta.partial_json是增量 JSON 片段,客户端按index=2聚合得到{"city":"北京","date":"today"}。message_delta.stop_reason = "tool_use"表示本轮停在等待客户端工具结果的位置。
这轮流里的 ID 关系可以按下表理解:
| ID | 来源 | 后续怎么用 |
|---|---|---|
`srvu_01` | server_tool_use | 上游服务内部执行,客户端不用回填 |
`srvr_01` | web_search_tool_result | 服务端工具结果块自己的 ID,仅用于描述该结果块 |
`toolu_01` | tool_use(get_weather) | 客户端执行本地工具后,用 tool_result.tool_use_id 回填 |
客户端侧聚合 SSE 时至少要维护两张临时表:
content_blocks[index] = started blockpartial_tool_inputs[index] += input_json_delta.partial_json当 content_block_stop(index=2) 到达时,客户端可以把 partial_tool_inputs[2] 解析成 JSON,并用同一个 block 的 ToolUseBlock.id 作为本地工具调用 ID。
本地工具结果回填
Section titled “本地工具结果回填”客户端执行本地 get_weather 后,下一轮请求把上一轮 assistant 的 tool_use 和新的 tool_result 一起放进 messages。示例省略前一轮的完整文本内容,只保留协议关键块:
{ "model": "claude-sonnet-4-5", "max_tokens": 1024, "stream": true, "messages": [ { "role": "user", "content": "北京今天适合跑步吗?如果空气质量不好,请查一下官方建议。" }, { "role": "assistant", "content": [ { "type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": { "city": "北京", "date": "today" } } ] }, { "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "toolu_01", "content": "北京今天气温 18-27C,轻度污染,PM2.5 约 85,傍晚有风。", "is_error": false } ] } ]}对应到本地结构:
- assistant content 里的
tool_use是ContentBlockParam::ToolUse(ToolUseBlockParam)。 - user content 里的
tool_result是ContentBlockParam::ToolResult(ToolResultBlockParam)。 ToolResultBlockParam.tool_use_id必须等于上一轮ToolUseBlock.id。
结构映射如下:
MessageCreateParamsBase { model: "claude-sonnet-4-5".to_string(), max_tokens: 1024, stream: Some(true), messages: vec![ MessageParam { role: Role::User, content: MessageParamContent::Text( "北京今天适合跑步吗?如果空气质量不好,请查一下官方建议。".to_string(), ), }, MessageParam { role: Role::Assistant, content: MessageParamContent::Blocks(vec![ ContentBlockParam::ToolUse(ToolUseBlockParam { id: "toolu_01".to_string(), name: "get_weather".to_string(), input: json!({ "city": "北京", "date": "today" }), ... }), ]), }, MessageParam { role: Role::User, content: MessageParamContent::Blocks(vec![ ContentBlockParam::ToolResult(ToolResultBlockParam { tool_use_id: "toolu_01".to_string(), content: Some(ToolResultContentParam::Text( "北京今天气温 18-27C,轻度污染,PM2.5 约 85,傍晚有风。" .to_string(), )), is_error: Some(false), ... }), ]), }, ], // 其他可选字段省略为 None。 ...}第二轮 SSE 最终回答
Section titled “第二轮 SSE 最终回答”上游拿到本地工具结果后继续生成最终文本:
event: message_startdata: { "type": "message_start", "message": { "id": "msg_02", "type": "message", "role": "assistant", "content": [], "model": "claude-sonnet-4-5", "usage": { "input_tokens": 210, "output_tokens": 1 } }}
event: content_block_startdata: { "type": "content_block_start", "index": 0, "content_block": { "type": "text", "text": "" }}
event: content_block_deltadata: { "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "今天北京不太适合高强度户外跑步。" }}
event: content_block_deltadata: { "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "空气质量为轻度污染,建议改为低强度慢跑或室内训练;如果外出,避开交通高峰并缩短时长。" }}
event: content_block_stopdata: { "type": "content_block_stop", "index": 0}
event: message_deltadata: { "type": "message_delta", "delta": { "stop_reason": "end_turn", "stop_sequence": null }, "usage": { "output_tokens": 48 }}
event: message_stopdata: { "type": "message_stop"}对应到本地结构:
MessageStreamEvent::MessageStart(MessageStartEvent { message: Message { id: "msg_02".to_string(), type_: MessageType::Message, role: Role::Assistant, content: vec![], model: "claude-sonnet-4-5".to_string(), usage: Usage { input_tokens: 210, output_tokens: 1, ... }, ... },})
MessageStreamEvent::ContentBlockStart(ContentBlockStartEvent { index: 0, content_block: ContentBlock::Text(TextBlock { text: String::new(), ... }),})
MessageStreamEvent::ContentBlockDelta(ContentBlockDeltaEvent { index: 0, delta: ContentBlockDelta::TextDelta(TextDelta { text: "今天北京不太适合高强度户外跑步。".to_string(), }),})
MessageStreamEvent::ContentBlockDelta(ContentBlockDeltaEvent { index: 0, delta: ContentBlockDelta::TextDelta(TextDelta { text: "空气质量为轻度污染,建议改为低强度慢跑或室内训练;如果外出,避开交通高峰并缩短时长。" .to_string(), }),})
MessageStreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 0 })
MessageStreamEvent::MessageDelta(MessageDeltaEvent { delta: MessageDelta { stop_reason: Some(StopReason::EndTurn), ... }, usage: MessageDeltaUsage { output_tokens: 48, ... },})
MessageStreamEvent::MessageStop(MessageStopEvent)这里的 TextDelta.text 是增量片段,不是到当前为止的完整文本。客户端需要按 index 找到对应 content block,然后顺序追加:
text_blocks[0] = ""text_blocks[0] += "今天北京不太适合高强度户外跑步。"text_blocks[0] += "空气质量为轻度污染,建议改为低强度慢跑或室内训练;如果外出,避开交通高峰并缩短时长。"当 ContentBlockStopEvent { index: 0 } 到达时,text_blocks[0] 才是这个文本块的完整内容。
最终回答的流式图示:
message_start | vcontent_block_start(index=0, text) | +-- text_delta: 今天北京不太适合... | +-- text_delta: 空气质量为轻度污染... | vcontent_block_stop(index=0) | vmessage_delta(stop_reason=end_turn) | vmessage_stopproxai 在这条链路中不重组这些事件。当前 Anthropic provider 默认保留原始 SSE bytes;AnthropicSseObserver 使用 SseEventScanner 扫描事件并归纳到 AnthropicResponseState,用于 completed/closed/error 日志。对 AnthropicCompatible provider,响应层会做 provider-local SSE payload normalization 后再输出。
这个例子刻意保留了三个边界:
- 请求头没有展开。真实 Anthropic 请求还需要 provider 侧认证和版本相关 headers;proxai 的 provider/auth 层负责把配置里的 key 转成上游需要的认证头。
- 服务端工具结果是示意性的。文档重点是
server_tool_use由上游执行、本地tool_use由客户端执行,而不是锁定某个 provider 的完整 web result JSON。 - 当前 proxai 不把 Anthropic SSE 重组为新的语义流,也不对 Anthropic 工具参数流做超时注入;
MessageStreamEvent用于 provider response 观察、摘要和跨协议转换。