跳转到内容

Anthropic 完整交互示例

返回:Anthropic Messages 协议

下面用一个简化场景串起客户端、本地工具、服务端工具和 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
v
proxai
|
| 2. 原样转发 anthropic_messages
v
Anthropic upstream
|
| 3. SSE: server_tool_use(web_search) + server result + tool_use(get_weather)
v
proxai
|
| 4. 原始 SSE bytes 透传
v
Client
|
| 5. 本地执行 get_weather
v
Local tool runtime
|
| 6. 下一轮 messages 带 tool_result
v
proxai -> 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_choiceToolChoice::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 流中,但语义不同。

event: message_start
data: {
"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_start
data: {
"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_stop
data: {
"type": "content_block_stop",
"index": 0
}
event: content_block_start
data: {
"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_stop
data: {
"type": "content_block_stop",
"index": 1
}
event: content_block_start
data: {
"type": "content_block_start",
"index": 2,
"content_block": {
"type": "tool_use",
"id": "toolu_01",
"name": "get_weather",
"caller": { "type": "direct" },
"input": {}
}
}
event: content_block_delta
data: {
"type": "content_block_delta",
"index": 2,
"delta": {
"type": "input_json_delta",
"partial_json": "{\"city\":\"北京\""
}
}
event: content_block_delta
data: {
"type": "content_block_delta",
"index": 2,
"delta": {
"type": "input_json_delta",
"partial_json": ",\"date\":\"today\"}"
}
}
event: content_block_stop
data: {
"type": "content_block_stop",
"index": 2
}
event: message_delta
data: {
"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_stop
data: {
"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_reasonstop_sequencecontainerstop_details
  • MessageDeltaEvent.usage 携带到这一刻为止的 token/server-tool usage 增量或汇总。

在这个例子里,stop_reason: ToolUse 表示模型已经产出一个本地工具调用,并主动停下来等待客户端回填 tool_resultserver_tool_use 则说明同一轮里上游已经执行过 1 次服务端 web search。

这段流对应的图示:

SSE content blocks
index=0 server_tool_use(web_search)
id=srvu_01
|
v
index=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,其中 contentWebSearchToolResultBlockContent,可以是错误或搜索结果数组。
  • 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 block
partial_tool_inputs[index] += input_json_delta.partial_json

content_block_stop(index=2) 到达时,客户端可以把 partial_tool_inputs[2] 解析成 JSON,并用同一个 block 的 ToolUseBlock.id 作为本地工具调用 ID。

客户端执行本地 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_useContentBlockParam::ToolUse(ToolUseBlockParam)
  • user content 里的 tool_resultContentBlockParam::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。
...
}

上游拿到本地工具结果后继续生成最终文本:

event: message_start
data: {
"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_start
data: {
"type": "content_block_start",
"index": 0,
"content_block": {
"type": "text",
"text": ""
}
}
event: content_block_delta
data: {
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "text_delta",
"text": "今天北京不太适合高强度户外跑步。"
}
}
event: content_block_delta
data: {
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "text_delta",
"text": "空气质量为轻度污染,建议改为低强度慢跑或室内训练;如果外出,避开交通高峰并缩短时长。"
}
}
event: content_block_stop
data: {
"type": "content_block_stop",
"index": 0
}
event: message_delta
data: {
"type": "message_delta",
"delta": {
"stop_reason": "end_turn",
"stop_sequence": null
},
"usage": {
"output_tokens": 48
}
}
event: message_stop
data: {
"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
|
v
content_block_start(index=0, text)
|
+-- text_delta: 今天北京不太适合...
|
+-- text_delta: 空气质量为轻度污染...
|
v
content_block_stop(index=0)
|
v
message_delta(stop_reason=end_turn)
|
v
message_stop

proxai 在这条链路中不重组这些事件。当前 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 观察、摘要和跨协议转换。