Agent教程22:AI大模型兼容,踩坑到崩溃

AI大模型兼容,踩坑到崩溃

前言

大模型厂商众多,OpenAI、Anthropic、Google Gemini、GitHub Copilot、xAI Grok......每个厂商的 API 接口设计都不尽相同。如果业务代码直接耦合某一家 SDK,换模型几乎等于重写。

本文介绍一种统一抽象层的设计思路:让业务代码只依赖一套接口协议,底层可以无缝切换任意大模型。同时兼容同步对话和流式输出,并支持工具调用(Function Calling)这一复杂能力。

本文展示核心设计决策和实现思路(文中仅用伪代码)。


1. 问题:模型差异在哪里?

做模型兼容之前,先看清楚差异到底有哪些。

1.1 消息结构的差异

OpenAI 系列(含 Copilot、Grok):

  • role: "assistant" 表示模型回复
  • role: "tool" 独立消息传递工具结果
json 复制代码
// OpenAI 工具结果
{ "role": "tool", "tool_call_id": "abc123", "content": "..." }

Anthropic

  • 同样用 role: "assistant" 表示模型回复
  • 工具结果内嵌在 user 消息的 content 数组里,不是独立消息
json 复制代码
// Anthropic 工具结果
{ "role": "user", "content": [
    { "type": "tool_result", "tool_use_id": "abc123", "content": "..." }
  ]}

Gemini

  • role: "model" 而不是 assistant
  • 工具结果用 functionResponse 作为 part 嵌入 user 消息
json 复制代码
// Gemini 工具结果
{ "role": "user", "parts": [
    { "functionResponse": { "name": "get_weather", "response": { "content": "..." } } }
  ]}

这还只是工具结果这一项差异。继续往下看。

1.2 System Prompt 的不同放置方式

模型 System Prompt 放在哪?
Anthropic 请求体中独立的 system 字段
OpenAI / Copilot / Grok 前置一条 role: "system" 的消息
Gemini 请求 config 中的 systemInstruction 字段

1.3 工具定义的结构差异

定义一个工具,OpenAI 用 parameters,Anthropic 用 input_schema,Gemini 用 parametersJsonSchema------名字都不一样,但语义完全相同。

1.4 停止原因(stop_reason)词汇不统一

模型停止生成的原因,各家叫法不同:

模型 可能的原始值 实际含义
OpenAI stop 对话正常结束
OpenAI tool_calls 调用了工具
Anthropic end_turn 对话正常结束
Gemini STOP 对话正常结束
任意 MAX_TOKENS token 耗尽

业务代码如果直接判断这些原始值,换模型就等于改业务逻辑。


2. 核心思路:抽象接口 + 适配器模式

解决方案是两层结构:

核心原则:适配器只做转换,不做业务决策。

每个适配器只负责三件事:

  1. 消息转换:把统一格式转成目标模型的 Wire Format
  2. 响应转换:把模型返回结果转回统一格式
  3. 流式事件 yield:按协议输出 text / tool_use / done / error

适配器不知道 token 花了多少,不知道要不要重试,更不知道业务在做什么。


3. 统一接口长什么样?

业务代码只需要认识这两个方法:

python 复制代码
# 伪代码
class LLMAdapter:
    name: str

    async def chat(
        messages: list[Message],
        options: ChatOptions,
    ) -> LLMResponse: ...

    async def stream(
        messages: list[Message],
        options: StreamOptions,
    ) -> AsyncIterator[StreamEvent]: ...

Message 是统一的消息结构,LLMResponse 是统一的响应结构。任何模型适配器返回的内容,结构完全一致。

3.1 统一的内容块

模型可能返回文本、工具调用、工具结果、图片。这些用"内容块"来统一表达:

python 复制代码
# 伪代码
class TextBlock:
    type: "text"
    text: str

class ToolUseBlock:
    type: "tool_use"
    id: str          # 本次工具调用的唯一 ID
    name: str        # 工具名
    input: dict      # 工具参数(已解析好的字典)

class ToolResultBlock:
    type: "tool_result"
    tool_use_id: str  # 关联到哪一次工具调用
    content: str | dict
    is_error: bool | None

class ImageBlock:
    type: "image"
    source: ImageSource

注意这里 ToolUseBlock.input 已经是 dict,而不是 JSON 字符串------解析工作由适配器完成,业务代码不需要处理原始参数字符串。

3.2 归一化的 stop_reason

适配器负责把模型原始的停止原因转换成统一词汇:

python 复制代码
# 伪代码
NORMALIZED_STOP_REASONS = {
    "stop":        "end_turn",       # OpenAI
    "tool_calls":  "tool_use",      # OpenAI
    "MAX_TOKENS":  "max_tokens",    # 任意
    "length":      "max_tokens",     # OpenAI
    "end_turn":    "end_turn",       # Anthropic
    "STOP":        "end_turn",       # Gemini
}

业务代码只需要判断三种停止原因:end_turn(对话结束)、tool_use(调用了工具)、max_tokens(token 耗尽)。换模型?不用改。


4. 工厂函数:隐藏适配器细节

业务代码不应该直接 new 某个适配器,而是通过工厂函数创建:

python 复制代码
# 伪代码
def create_adapter(
    provider: Literal["anthropic", "openai", "copilot", "grok", "gemini"],
    api_key: str | None = None,
    base_url: str | None = None,
) -> LLMAdapter:
    # 工厂函数负责:
    # 1. 环境变量解析(每个 provider 映射不同 env key)
    # 2. 实例化正确的适配器
    # 3. 返回新实例(每次调用新建,无单例)

这样业务代码在切换模型时,只需要改一个字符串:

python 复制代码
# 伪代码
llm = create_adapter("anthropic")       # 换成 Claude
llm = create_adapter("openai", base_url="https://api.deepseek.com")  # 换成 DeepSeek

5. 工具调用:最难的部分

工具调用是兼容层最复杂的点,涉及三个维度:工具定义格式、工具结果传递、流式参数拼接。前两个维度在前文已覆盖,本节重点讲流式处理各家差异------这是最容易出 bug 的地方。

5.1 各家流式处理全对比

OpenAI(含 Copilot、Grok)

流式返回的是 function.arguments 字符串片段,逐步推送:

ini 复制代码
[chunk] delta.function.arguments = '{"city":'
[chunk] delta.function.arguments = ' "Seatt'
[chunk] delta.function.arguments = 'le"}'

适配器按 index(工具调用在列表中的位置)分 buffer 累积:

css 复制代码
buffer[0] = { id: "call_abc", name: "get_weather", raw_args: '{"city": "Seattle"}' }

流结束后一次性 JSON.parse(buffer[0].raw_args),得到 {city: "Seattle"}

Anthropic

Anthropic SDK 对外暴露的是 content_block_delta 事件,其中 input_json_delta 类型专用于工具参数:

ini 复制代码
[chunk] type="content_block_start", index=0, block.type="tool_use", id="toolu_xyz", name="get_weather"
[chunk] type="content_block_delta", index=0, delta.type="input_json_delta", partial_json='{"city":'
[chunk] type="content_block_delta", index=0, delta.type="input_json_delta", partial_json=' "Seattle"}'
[chunk] type="content_block_delta", index=0, delta.type="text_delta", text="\n\n"  ← 可能穿插文本
[chunk] type="content_block_stop", index=0

适配器同样按 index 累积 partial_json 到 buffer。注意:input_json_delta 已经是合法的 JSON 字符串片段(带引号和逗号),累积完毕后直接 parse 即可。

Gemini

Gemini SDK 根本不流式返回参数。functionCall 以完整对象形式一次性到达:

css 复制代码
[chunk] part.functionCall = {
  id: "gemini-call-001",
  name: "get_weather",
  args: { city: "Seattle" }   ← args 已是 dict,不是 JSON 字符串
}

这意味着 Gemini 适配器不需要任何 buffer 逻辑,遇到 functionCall 直接 yield 即可。

Ollama / 本地模型(vLLM 等)

这类 OpenAI 兼容 API 有时会"偷懒":工具调用其实是通过文本返回的,而不是用 tool_calls 字段:

css 复制代码
[chunk] delta.content = 'I will use get_weather with {"city": "Seattle"}'

适配器需要检测到这种情况,在流结束后从文本中正则提取 出 JSON 来构造 ToolUseBlock。这是一种兼容性兜底逻辑。


5.2 完整流式状态机(OpenAI 为例)

ini 复制代码
请求发出
    │
    ▼
S1: 收到 chunk
    ├── delta.content != null  → yield text 事件
    │
    └── delta.tool_calls != null
          ├── index=0, id="call_abc", name="get_weather"
          │   → 初始化 buffer[0] = {id, name, raw_args: ""}
          └── index=0, function.arguments='{"city":'
              → 拼入 buffer[0].raw_args += '{"city":'
    │
    ▼
S2: finish_reason != null  → 记录最终 finish_reason
    │
    ▼
S3: 流结束,遍历 buffer,按 index 顺序
    ├── buffer[0]: raw_args='{"city": "Seattle"}'
    │                JSON.parse → input={city: "Seattle"}
    │                yield tool_use 事件
    └── buffer[1]: raw_args='{"query": "news"}'
                   JSON.parse → input={query: "news"}
                   yield tool_use 事件
    │
    ▼
S4: yield done 事件(携带 final LLMResponse)

5.3 统一策略:collect-then-parse

不管底层是哪种模式,适配器对外的流式接口必须统一。核心原则只有一条:不区分来源,一致地累积,一致地输出

python 复制代码
# 伪代码
async def stream(self, messages, options) -> AsyncIterator[StreamEvent]:
    # key = index(工具调用在列表中的位置)
    tool_buffers: dict[int, ToolBuffer] = {}
    # ToolBuffer = { id: str, name: str, raw_args: str }

    async for chunk in model_stream(messages, options):
        # --- 文本处理:各模型一致 ---
        if chunk.has_text:
            yield StreamEvent(type="text", data=chunk.text)

        # --- 工具调用处理:按模型分发 ---
        if chunk.provider == "openai":
            for tc in chunk.tool_calls or []:
                ensure_buffer(tc.index, tc.id, tc.name)
                if tc.arguments:
                    tool_buffers[tc.index].raw_args += tc.arguments

        elif chunk.provider == "anthropic":
            for delta in chunk.content_block_deltas:
                if delta.type == "input_json_delta":
                    ensure_buffer(delta.index, delta.id, delta.name)
                    tool_buffers[delta.index].raw_args += delta.partial_json

        elif chunk.provider == "gemini":
            for fc in chunk.function_calls:
                yield StreamEvent(type="tool_use", data=ToolUseBlock(
                    id=fc.id or fabricate_id(),
                    name=fc.name,
                    input=fc.args,   # args 已是 dict,无需 parse
                ))
            continue   # Gemini 不走下面的统一解析逻辑

    # --- 统一解析:OpenAI / Anthropic ---
    for buf in tool_buffers.values():
        try:
            parsed = json.loads(buf.raw_args)
        except json.JSONDecodeError:
            parsed = {}   # 解析失败,优雅降级,不崩溃
        yield StreamEvent(type="tool_use", data=ToolUseBlock(
            id=buf.id, name=buf.name, input=parsed
        ))

    yield StreamEvent(type="done", data=final_response)

关键点:

  • 流式过程中不解析 JSON :片段来了只累积。理由很简单------JSON 字符串在流式传输中途往往是不完整的({"city":),此时 parse 必然失败。
  • 解析失败不崩溃try/except 包裹 json.loads,失败时返回空字典 {},调用方会收到参数为空的工具调用,可以在业务层决定是重试还是报错。
  • Gemini 是例外 :SDK 已经把参数解析成 dict,不需要 buffer,直接 yield。这是唯一一个不走 collect-then-parse 流程的模型。

5.4 并行工具调用的处理

有时候模型会一次性并行调用多个工具,这些调用在流式返回时通过 index 字段区分:

ini 复制代码
[chunk] tool_calls[0]: id="call_1", name="get_weather", arguments='{"city": "Seattle"}'
[chunk] tool_calls[1]: id="call_2", name="get_time",    arguments='{"tz": "PST"}'
[chunk] tool_calls[0]: arguments='...下一块的片段...'
[chunk] tool_calls[1]: arguments='...下一块的片段...'

buffer 按 index 组织,各自独立累积,互不干扰。流结束时按 index 顺序 yield(indexint,天然有序)。


6. 认证:适配器的"隐形"工作

每个模型的 API key 环境变量名不同:

模型 环境变量
Anthropic ANTHROPIC_API_KEY
OpenAI OPENAI_API_KEY
Gemini GEMINI_API_KEY / GOOGLE_API_KEY
Grok XAI_API_KEY
Copilot GITHUB_COPILOT_TOKEN / GITHUB_TOKEN(还支持 OAuth2 交互式认证)

工厂函数负责根据 provider 映射到正确的环境变量,适配器只接收一个已经解析好的 API key,保持职责单一。


7. 错误处理契约

适配器错误处理遵循三条规则:

  1. API 错误透传:非 2xx 响应或 SDK 异常不捕获,直接抛给调用方。调用方才知道要不要重试、怎么重试。

  2. 解析失败不崩溃:工具参数 JSON 解析失败,返回空字典,不中断流。

  3. 未知块类型不丢弃:模型返回了某个我们不认识的内容块类型时,降级成文本块返回,保证数据不丢失。


8. 架构收益

有了这层抽象,能得到什么?

换模型零感知:从 Claude 换到 GPT-5,业务代码一行不用改,改个 provider 字符串就够了。

工具调用一套实现:不管底层是 OpenAI 的 tool_calls 还是 Anthropic 的 content_block,流式处理逻辑完全统一。

可测试性强 :直接 mock LLMAdapter 接口,不需要真实的 API key 就能测试业务逻辑。

新增模型成本低:实现一个新适配器,只需要关注格式转换,不影响任何已有代码。


结语

模型厂商的接口差异是表面,本质都是"输入消息,输出 token"。找到这个本质,用适配器模式把差异封装在底层,业务代码就能真正做到"跟厂商解耦"。

这套设计已经在 TypeScript 项目中验证过,思路同样适用于 Python、Go 或任何有类型系统的语言。核心在于那两条原则:统一接口收敛一切差异,适配器只做格式转换不做业务

相关推荐
regret~2 小时前
【记录】前端创建
前端
深念Y2 小时前
前端实时通信技术:HTTP轮询、SSE、WebSocket、WebRTC
前端·websocket·网络协议·http·实时互动·轮询·实时通信
bKYP953cL2 小时前
Flask - 常见应用部署方案
后端·python·flask
希望永不加班2 小时前
SpringBoot 多模块项目搭建:service/dao/web分层设计
java·前端·spring boot·后端·spring
邂逅星河浪漫2 小时前
【Vue2-ElementUI】:model、v-model、prop
javascript·vue.js·elementui
Victor3562 小时前
MongoDB(86)如何使用MongoDB存储大文件?
后端
cch89182 小时前
ThinkPHP3.x核心特性全解析
开发语言·后端·golang
Victor3562 小时前
MongoDB(85)如何实现全文搜索?
后端
小江的记录本2 小时前
【JEECG Boot】JEECG Boot 系统性知识体系全方位结构化总结
java·前端·spring boot·后端·python·spring·spring cloud