原文:OpenAI vs Anthropic API 对比:响应体、请求体、消息格式与工具调用 --- 作者 冯叶青
本文在原文基础上,新增 第六节:流式返回场景下的 Adapt 适配层实现。
一、响应体格式区别
OpenAI 响应结构
json
{
"id": "chatcmpl-xxx",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "回复内容"
},
"finish_reason": "stop"
}
],
"usage": { ... }
}
Anthropic 响应结构
json
{
"id": "msg_xxx",
"type": "message",
"role": "assistant",
"content": [
{ "type": "text", "text": "回复内容" }
],
"model": "claude-3-sonnet-...",
"stop_reason": "end_turn",
"usage": { ... }
}
| 特性 | OpenAI | Anthropic |
|---|---|---|
| 消息位置 | choices[0].message |
顶层 role + content |
content 类型 |
字符串(或数组) | 数组(纯文本也需数组形式) |
| 结束原因字段 | finish_reason |
stop_reason |
| 判断依据 | 存在 "choices" → OpenAI |
存在 "type": "message" → Anthropic |
二、请求体格式区别
OpenAI
json
{
"model": "gpt-4",
"messages": [
{ "role": "system", "content": "你是一个助手" },
{ "role": "user", "content": "你好" }
],
"temperature": 0.7,
"max_tokens": 1000
}
Anthropic
json
{
"model": "claude-3-sonnet-20240229",
"system": "你是一个助手",
"messages": [
{ "role": "user", "content": "你好" }
],
"temperature": 0.7,
"max_tokens": 1000
}
| 特性 | OpenAI | Anthropic |
|---|---|---|
| 系统提示 | messages 中 role: "system" |
顶层 system 字段 |
| 消息角色 | system, user, assistant, tool |
仅 user, assistant |
max_tokens |
可选 | 必填 |
content 类型 |
字符串 | 必须数组 |
| 角色顺序 | 无强制交替 | 严格交替,首条必为 user |
三、消息角色与格式注意事项
OpenAI 角色
system:设定行为user:用户输入assistant:模型回复tool:工具调用结果
Anthropic 角色
仅 user、assistant。系统指令在顶层 system 字段。
严格约束:
- ❌ 首条非
user→ 报错 - ❌ 未严格交替 → 报错
- ❌
content为字符串 → 失败 - ❌ 最后一条 assistant 消息尾随空白 → 特殊失败
四、工具调用完整示例
4.1 OpenAI --- 第一轮(定义工具 → 返回 tool_calls)
json
{
"model": "gpt-4o",
"messages": [
{ "role": "user", "content": "北京天气怎么样?" }
],
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的天气",
"parameters": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
}
}
}]
}
返回:
json
{
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_abc123",
"type": "function",
"function": { "name": "get_weather", "arguments": "{\"city\":\"北京\"}" }
}]
}
}]
}
4.1 OpenAI --- 第二轮(回传工具结果)
json
{
"messages": [
{ "role": "user", "content": "北京天气怎么样?" },
{ "role": "assistant", "content": null, "tool_calls": [...] },
{ "role": "tool", "tool_call_id": "call_abc123", "content": "{\"temperature\":\"22°C\"}" }
]
}
4.2 Anthropic --- 第一轮
json
{
"model": "claude-3-sonnet-20240229",
"system": "You are a helpful assistant.",
"messages": [
{ "role": "user", "content": [{ "type": "text", "text": "北京天气怎么样?" }] }
],
"tools": [{
"name": "get_weather",
"description": "获取指定城市的天气",
"input_schema": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
}
}],
"max_tokens": 1024
}
返回:
json
{
"role": "assistant",
"content": [
{ "type": "text", "text": "好的,我来查询北京的天气。" },
{ "type": "tool_use", "id": "toolu_01AbC", "name": "get_weather", "input": { "city": "北京" } }
],
"stop_reason": "tool_use"
}
4.2 Anthropic --- 第二轮
json
{
"messages": [
{ "role": "user", "content": [...] },
{ "role": "assistant", "content": [ /* 全文 */ ] },
{ "role": "user", "content": [{
"type": "tool_result", "tool_use_id": "toolu_01AbC",
"content": "{\"temperature\":\"22°C\"}"
}] }
]
}
五、总结与建议
| 对比维度 | OpenAI | Anthropic |
|---|---|---|
| 设计哲学 | messages 统一时序容器 |
分离系统指令与对话历史 |
| 角色丰富度 | 4 种角色 | 2 种核心角色 |
| 严格程度 | 宽容 | 严格交替、首条必为 user |
| 工具调用 | 独立 tool 角色 + tool_calls |
tool_use/tool_result 块嵌入 content |
| 易错点 | 角色名拼错、缺少字段 | 内容非数组、未交替、尾随空格 |
六、流式返回场景下的 Adapt 适配层实现
前面讲的都是**非流式(一次返回完整结果)**的场景。但在实际产品中,几乎都会用流式(Streaming)来提升用户体验------逐字输出,不用等。
问题来了:OpenAI 和 Anthropic 的流式 SSE 事件格式、字段名、语义完全不一样。如果业务代码直接对接两种格式,很快就会变成意大利面条。
6.1 两种 API 的流式格式一览
OpenAI 流式 SSE 事件
json
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"你"},"finish_reason":null}]}
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"好"},"finish_reason":null}]}
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: [DONE]
关键字段:
- 文本增量 :
choices[0].delta.content - 工具调用增量 :
choices[0].delta.tool_calls[0].function.name/.arguments - 结束 :
choices[0].finish_reason非 null,或收到[DONE]
Anthropic 流式 SSE 事件
json
event: message_start
data: {"type":"message_start","message":{"id":"msg_xxx","role":"assistant","content":[],"model":"claude-sonnet-4-20250514","usage":{"input_tokens":15}}}
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"},"usage":{"output_tokens":8}}
event: message_stop
data: {"type":"message_stop"}
关键事件类型:
| 事件 type | 含义 |
|---|---|
message_start |
消息开始,含 input_tokens |
content_block_start |
内容块开始(text 或 tool_use) |
content_block_delta |
内容增量(text_delta 或 input_json_delta) |
content_block_stop |
内容块结束 |
message_delta |
消息级增量(stop_reason, output_tokens) |
message_stop |
流结束 |
ping |
心跳 |
工具调用流式示例:
json
event: content_block_start
data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01X","name":"get_weather","input":{}}}
event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"city\":\"北京\"}"}}
event: content_block_stop
data: {"type":"content_block_stop","index":1}
6.2 核心矛盾
把它们并排放在一起看,差距一目了然:
OpenAI 流:一个 flat 的 JSON chunk 流,每个 chunk 带一个 delta 字段
Anthropic 流:一个事件机(状态机),有 start/delta/stop 生命周期
直接在上层业务里 if (openai) ... else if (anthropic) ... 当然可以,但你很快会遇到:
- 工具调用时 OpenAI 是
tool_calls[0].function.arguments逐 chunk 拼接 JSON 字符串,而 Anthropic 是input_json_delta.partial_json - 结束语义:OpenAI 靠
finish_reason和[DONE],Anthropic 靠message_delta.stop_reason和message_stop - 用量信息:OpenAI 在最后的 chunk 或没有,Anthropic 在
message_start和message_delta中分两次给
6.3 统一抽象:设计一个 Provider 无关的事件模型
与其让上层分别解析,不如定义一套统一事件(UnifiedEvent),让每个 Provider 的 Adapter 各自翻译。
python
# ============================================================
# adapters/types.py --- 统一事件定义(业务层只和这个打交道)
# ============================================================
from dataclasses import dataclass
from typing import Literal, Union
@dataclass
class UsageInfo:
input_tokens: int
output_tokens: int
@dataclass
class MessageStart:
type: Literal["message_start"] = "message_start"
message_id: str = ""
model: str = ""
@dataclass
class TextDelta:
type: Literal["text_delta"] = "text_delta"
text: str = ""
@dataclass
class ToolCallStart:
type: Literal["tool_call_start"] = "tool_call_start"
tool_call_id: str = ""
name: str = ""
@dataclass
class ToolCallDelta:
type: Literal["tool_call_delta"] = "tool_call_delta"
tool_call_id: str = ""
partial_json: str = ""
@dataclass
class ToolCallEnd:
type: Literal["tool_call_end"] = "tool_call_end"
tool_call_id: str = ""
@dataclass
class MessageEnd:
type: Literal["message_end"] = "message_end"
stop_reason: str | None = None
usage: UsageInfo | None = None
@dataclass
class ErrorEvent:
type: Literal["error"] = "error"
code: str = ""
message: str = ""
# 联合类型:任何 yield 出去的事件都是 UnifiedEvent
UnifiedEvent = Union[
MessageStart,
TextDelta,
ToolCallStart,
ToolCallDelta,
ToolCallEnd,
MessageEnd,
ErrorEvent,
]
用
@dataclass而非TypedDict的好处:类型检查更友好、可以有默认值、IDE 自动补全更准。
6.4 Adapt 层实现
核心思路:每个 Provider 的 Adapter 是一个 async generator (async def + yield),吃进原始 SSE 行,吐出统一事件。
6.4.1 OpenAI Streaming Adapter
python
# ============================================================
# adapters/openai_adapter.py --- OpenAI SSE → UnifiedEvent
# ============================================================
import json
from typing import AsyncIterator
from .types import (
UnifiedEvent,
MessageStart, TextDelta,
ToolCallStart, ToolCallDelta, ToolCallEnd,
MessageEnd, UsageInfo,
)
async def adapt_openai_stream(
sse_lines: AsyncIterator[str],
) -> AsyncIterator[UnifiedEvent]:
"""OpenAI SSE 流 → 统一事件流
OpenAI 的流式格式很 flat,每个 data: 行就是一个 JSON chunk。
关键任务:
1. 从 delta.content 提取文本增量
2. 从 delta.tool_calls 提取工具调用增量(流式 JSON 片段拼接)
3. finish_reason 非 None → message_end
4. [DONE] → 流终止
"""
message_id = ""
model = ""
started = False
input_tokens = 0
output_tokens = 0
# 跟踪工具调用(OpenAI 按 index 区分,id 只在首个 chunk 给出)
tool_name_map: dict[int, str] = {} # index → name
tool_id_map: dict[int, str] = {} # index → tool_call_id
async for line in sse_lines:
# 跳过空行和注释
line = line.strip()
if not line or line.startswith(":"):
continue
# 解析 data: 前缀
if not line.startswith("data: "):
continue
payload = line[6:]
# 终止信号
if payload == "[DONE]":
break
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
continue
# 首次 chunk,提取元信息
if not started:
message_id = chunk.get("id", "")
model = chunk.get("model", "")
started = True
yield MessageStart(message_id=message_id, model=model)
choices = chunk.get("choices", [])
if not choices:
continue
choice = choices[0]
delta = choice.get("delta", {})
finish_reason = choice.get("finish_reason")
# --- 文本增量 ---
content = delta.get("content")
if content:
yield TextDelta(text=content)
# --- 工具调用增量 ---
tool_calls = delta.get("tool_calls")
if tool_calls:
for tc in tool_calls:
idx = tc.get("index", 0)
# 第一次出现 → tool_call_start
tc_id = tc.get("id")
if tc_id:
name = tc.get("function", {}).get("name", "")
tool_id_map[idx] = tc_id
tool_name_map[idx] = name
yield ToolCallStart(tool_call_id=tc_id, name=name)
# 参数增量
fn = tc.get("function", {})
arguments = fn.get("arguments")
if arguments:
tc_id = tc.get("id") or tool_id_map.get(idx, "")
yield ToolCallDelta(
tool_call_id=tc_id,
partial_json=arguments,
)
# --- 结束判断 ---
if finish_reason:
usage = chunk.get("usage")
if usage:
input_tokens = usage.get("prompt_tokens", 0)
output_tokens = usage.get("completion_tokens", 0)
yield MessageEnd(
stop_reason=finish_reason,
usage=UsageInfo(
input_tokens=input_tokens,
output_tokens=output_tokens,
),
)
6.4.2 Anthropic Streaming Adapter
python
# ============================================================
# adapters/anthropic_adapter.py --- Anthropic SSE → UnifiedEvent
# ============================================================
import json
from typing import AsyncIterator
from .types import (
UnifiedEvent,
MessageStart, TextDelta,
ToolCallStart, ToolCallDelta, ToolCallEnd,
MessageEnd, ErrorEvent, UsageInfo,
)
async def adapt_anthropic_stream(
sse_lines: AsyncIterator[str],
) -> AsyncIterator[UnifiedEvent]:
"""Anthropic SSE 流 → 统一事件流
Anthropic 的流是一个事件机,有多种事件类型,每种有不同的生命周期语义。
关键任务:
1. message_start → 提取模型名、input_tokens
2. content_block_start(tool_use) → tool_call_start
3. content_block_delta(text_delta) → text_delta
4. content_block_delta(input_json_delta) → tool_call_delta
5. content_block_stop → tool_call_end(如果当前块是工具块)
6. message_delta → 提取 stop_reason、output_tokens
7. message_stop → message_end + 流终止
"""
message_id = ""
model = ""
input_tokens = 0
output_tokens = 0
stop_reason: str | None = None
# 跟踪当前内容块的状态:index → ("text" | "tool_use")
block_types: dict[int, str] = {}
# index → tool_use_id
block_tool_ids: dict[int, str] = {}
async for line in sse_lines:
line = line.strip()
if not line or line.startswith(":"):
continue
# Anthropic SSE 可能有 event: 行和 data: 行
# 简化处理:只解析 data: 行
if not line.startswith("data: "):
continue
payload = line[6:]
try:
event = json.loads(payload)
except json.JSONDecodeError:
continue
event_type = event.get("type")
if event_type == "message_start":
msg = event.get("message", {})
message_id = msg.get("id", "")
model = msg.get("model", "")
input_tokens = msg.get("usage", {}).get("input_tokens", 0)
yield MessageStart(message_id=message_id, model=model)
elif event_type == "content_block_start":
block = event.get("content_block", {})
idx = event.get("index", 0)
if block.get("type") == "tool_use":
block_types[idx] = "tool_use"
tool_id = block.get("id", "")
block_tool_ids[idx] = tool_id
yield ToolCallStart(
tool_call_id=tool_id,
name=block.get("name", ""),
)
elif block.get("type") == "text":
block_types[idx] = "text"
elif event_type == "content_block_delta":
idx = event.get("index", 0)
delta = event.get("delta", {})
if delta.get("type") == "text_delta":
text = delta.get("text", "")
if text:
yield TextDelta(text=text)
elif delta.get("type") == "input_json_delta":
partial = delta.get("partial_json", "")
if partial:
tool_id = block_tool_ids.get(idx, f"tool_{idx}")
yield ToolCallDelta(
tool_call_id=tool_id,
partial_json=partial,
)
elif event_type == "content_block_stop":
idx = event.get("index", 0)
if block_types.get(idx) == "tool_use":
tool_id = block_tool_ids.get(idx, f"tool_{idx}")
yield ToolCallEnd(tool_call_id=tool_id)
# 清理
block_types.pop(idx, None)
block_tool_ids.pop(idx, None)
elif event_type == "message_delta":
stop_reason = event.get("delta", {}).get("stop_reason")
output_tokens = event.get("usage", {}).get("output_tokens", 0)
elif event_type == "message_stop":
yield MessageEnd(
stop_reason=stop_reason,
usage=UsageInfo(
input_tokens=input_tokens,
output_tokens=output_tokens,
),
)
elif event_type == "ping":
# 心跳,忽略
pass
elif event_type == "error":
err = event.get("error", {})
yield ErrorEvent(
code=err.get("type", "unknown"),
message=err.get("message", "Unknown error"),
)
6.5 统一入口:stream_adapter
把两个 Adapter 包装在一个统一入口里,业务代码只需要告诉它用的是哪个 Provider:
python
# ============================================================
# adapters/stream_adapter.py --- 统一流式适配器入口
# ============================================================
import json
from typing import AsyncIterator, Literal
import httpx # 或 aiohttp,这里是 httpx 的用法
from .types import UnifiedEvent
from .openai_adapter import adapt_openai_stream
from .anthropic_adapter import adapt_anthropic_stream
Provider = Literal["openai", "anthropic"]
async def stream_adapter(
provider: Provider,
response: httpx.Response,
) -> AsyncIterator[UnifiedEvent]:
"""统一流式适配器入口
用法:
async with httpx.AsyncClient() as client:
async with client.stream("POST", url, json=body, headers=headers) as resp:
async for event in stream_adapter("openai", resp):
# 处理 UnifiedEvent
"""
line_stream = _read_sse_lines(response)
if provider == "openai":
async for event in adapt_openai_stream(line_stream):
yield event
elif provider == "anthropic":
async for event in adapt_anthropic_stream(line_stream):
yield event
else:
raise ValueError(f"Unsupported provider: {provider}")
async def _read_sse_lines(
response: httpx.Response,
) -> AsyncIterator[str]:
"""工具函数:把 httpx 流式响应按行拆分(SSE 底层)
等价于 TypeScript 版的 readLines()。
"""
buffer = ""
async for chunk in response.aiter_bytes():
buffer += chunk.decode("utf-8")
# 按 \n 拆分,最后一段不完整留着下次拼
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
yield line
# 处理最后残留
if buffer:
yield buffer
依赖说明 :代码用
httpx做 HTTP 客户端。如果你用aiohttp,只需要把response.aiter_bytes()换成response.content.iter_chunked()即可,其余逻辑完全不变。
6.6 业务层使用示例
有了 Adapt 层之后,业务代码干净得像这样:
python
# ============================================================
# 业务层 --- 只需 import stream_adapter,零 if-else
# ============================================================
import httpx
from adapters.stream_adapter import stream_adapter, Provider
from adapters.types import (
MessageStart, TextDelta,
ToolCallStart, ToolCallDelta, ToolCallEnd,
MessageEnd, ErrorEvent,
)
async def chat(
provider: Provider,
messages: list[dict],
tools: list[dict] | None = None,
*,
on_text: callable = None,
on_tool_call: callable = None,
) -> str:
"""统一 chat 入口,自动适配 OpenAI / Anthropic 流式返回"""
# 1. 构造请求(由另一个 Adapter 负责,此处略)
url, headers, body = build_request(provider, messages, tools, stream=True)
# 2. 发起流式请求
full_text = ""
tool_calls: dict[str, dict] = {} # tool_call_id → {"name": ..., "args": ...}
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client:
async with client.stream("POST", url, json=body, headers=headers) as resp:
resp.raise_for_status()
# 3. 用统一适配器消费流 --- 关键只有这一行
async for event in stream_adapter(provider, resp):
match event:
case TextDelta(text=text):
full_text += text
if on_text:
on_text(text)
case ToolCallStart(tool_call_id=tc_id, name=name):
tool_calls[tc_id] = {"name": name, "args": ""}
case ToolCallDelta(tool_call_id=tc_id, partial_json=pj):
if tc_id in tool_calls:
tool_calls[tc_id]["args"] += pj
case ToolCallEnd(tool_call_id=tc_id):
tc = tool_calls.get(tc_id)
if tc and on_tool_call:
on_tool_call(
id=tc_id,
name=tc["name"],
arguments=tc["args"],
)
case MessageEnd(stop_reason=sr, usage=u):
print(f"[Done] stop_reason={sr}, "
f"input={u.input_tokens if u else '?'}, "
f"output={u.output_tokens if u else '?'}")
case ErrorEvent(code=code, message=msg):
raise RuntimeError(f"[{code}] {msg}")
return full_text
Python 3.10+ 的
match/case配合@dataclass简直是绝配------事件分发清晰得像模式匹配语言。
6.7 双协议适配对照表
| 场景 | OpenAI 原始 | Anthropic 原始 | 统一事件 |
|---|---|---|---|
| 文本增量 | choices[0].delta.content |
content_block_delta.text_delta.text |
TextDelta |
| 工具调用开始 | 首个 delta.tool_calls[i] 带 id |
content_block_start 类型为 tool_use |
ToolCallStart |
| 工具参数增量 | delta.tool_calls[i].function.arguments(字符串片段) |
content_block_delta.input_json_delta.partial_json |
ToolCallDelta |
| 工具调用结束 | 无显式事件(靠下一个 delta 判断) | content_block_stop |
ToolCallEnd |
| 消息结束 | finish_reason 非 None |
message_delta.stop_reason |
MessageEnd |
| 流终止 | data: [DONE] |
message_stop |
break(generator 自然退出) |
| 输入 tokens | 首 chunk 的 usage.prompt_tokens 或结尾 chunk |
message_start.usage.input_tokens |
MessageEnd.usage.input_tokens |
| 输出 tokens | 结尾 chunk 的 usage.completion_tokens |
message_delta.usage.output_tokens |
MessageEnd.usage.output_tokens |
| 错误 | {"error": {...}} 或 HTTP 状态码 |
{"type":"error", "error":{...}} |
ErrorEvent |
6.8 设计要点总结
-
Python async generator 是天然的适配器接口 。
async def+yield惰性求值、内存友好,比回调方案优雅得多。 -
工具调用的流式处理是最大的坑 。OpenAI 把 JSON 参数直接当字符串片段给,不需要拼接符号;Anthropic 给的是
partial_json------两者语义相同但字段路径完全不同。Adapt 层统一成ToolCallDelta.partial_json后,业务层只需要+=拼接即可。 -
用量信息收集时机不同 。OpenAI 的 usage 可能在首 chunk(
stream_options={"include_usage": True})也可能在尾 chunk。Anthropic 的 input_tokens 在message_start,output_tokens 在message_delta。Adapt 层内部缓冲,统一在MessageEnd一次性吐出。 -
错误处理要覆盖两种协议 。OpenAI 可能返回非 200 的 HTTP 响应(body 里是
{"error": ...}),Anthropic 在流内发{"type": "error"}。Adapt 层统一转成ErrorEvent。 -
容易漏掉的细节:
- Anthropic 的
ping心跳事件要静默忽略 - OpenAI 的
[DONE]不是合法 JSON,要特殊处理 - SSE 行可能以
:开头(注释行),要跳过 - Anthropic 的
content_block_start中input: {}表示工具参数尚未到齐 - OpenAI 工具调用用
index关联、id只在首个 fragment 出现,Adapt 层需要按 index 维护映射表
- Anthropic 的
6.9 扩展:加上 Anthropic 的 event: 行完整解析
上面 adapt_anthropic_stream 做了简化------忽略 event: 行,只用 data: 行。在生产环境中,更稳健的做法是同时处理 event: 行来区分事件类型:
python
async def adapt_anthropic_stream_full(
sse_lines: AsyncIterator[str],
) -> AsyncIterator[UnifiedEvent]:
"""完整版:同时解析 event: 和 data: 行"""
current_event = ""
# ... 同上面 adapt_anthropic_stream 的状态变量 ...
async for line in sse_lines:
line = line.strip()
if line.startswith("event: "):
current_event = line[7:].strip()
continue
if line.startswith("data: "):
payload = line[6:]
try:
event = json.loads(payload)
except json.JSONDecodeError:
continue
# current_event 可用于额外校验,例如:
# if current_event == "ping" and event.get("type") != "ping":
# logger.warning(f"event/data mismatch: {current_event=} {event=}")
# 后续处理同 adapt_anthropic_stream...
# 空行表示一个事件的结束
if not line:
current_event = ""
七、完整的项目结构建议
adapters/
__init__.py # 对外导出 stream_adapter + 所有类型
types.py # UnifiedEvent, UsageInfo 等 @dataclass 定义
openai_adapter.py # OpenAI → Unified 的请求/响应/流式适配
anthropic_adapter.py # Anthropic → Unified 的请求/响应/流式适配
stream_adapter.py # stream_adapter() 统一入口 + _read_sse_lines
这样,当你需要接入第三个 Provider(比如 Google Gemini、国内模型),只需要新增一个 xxx_adapter.py,业务代码零改动。
安装依赖:
bash
pip install httpx
愿你对接的 API 永远向后兼容,SSE 行从不丢帧。