最近在做一个 LLM API 网关项目,核心需求是让 Claude Code、Cursor 这类 AI 编程工具能够通过统一的接口访问不同厂商的模型。这篇文章分享协议转换和模型路由两个核心模块的实现思路。
一、为什么需要协议转换
目前主流的大模型 API 主要有两套协议:
OpenAI 协议(Chat Completions)
json
{
"model": "gpt-4o",
"messages": [
{ "role": "user", "content": "Hello" }
],
"stream": true
}
Anthropic 协议(Messages)
json
{
"model": "claude-sonnet-4-20250514",
"max_tokens": 4096,
"messages": [
{ "role": "user", "content": "Hello" }
]
}
看起来差不多,但在 tool use、streaming、system prompt 的处理上差异很大。如果只支持 OpenAI 格式,Claude Code 发的 Anthropic 原生请求就会直接 404。
二、协议转换的核心差异点
2.1 System Prompt
OpenAI 的 system 消息直接在 messages 数组里,Anthropic 有独立的 system 字段:
javascript
function anthropicToOpenAI(anthropicReq) {
const messages = [];
// Anthropic 的 system 字段 → OpenAI 的 system message
if (anthropicReq.system) {
const systemText = Array.isArray(anthropicReq.system)
? anthropicReq.system.map(s => s.text).join('\n')
: anthropicReq.system;
messages.push({ role: 'system', content: systemText });
}
// 转换 messages
for (const msg of anthropicReq.messages) {
messages.push(convertMessage(msg));
}
return {
model: anthropicReq.model,
messages,
stream: anthropicReq.stream,
max_tokens: anthropicReq.max_tokens,
temperature: anthropicReq.temperature,
};
}
2.2 Tool Use 的 Content Block 结构
这是最复杂的部分。Anthropic 的 tool_use 和 tool_result 是结构化的 content block:
json
// Anthropic assistant 消息中的 tool_use
{
"role": "assistant",
"content": [
{ "type": "text", "text": "Let me check..." },
{ "type": "tool_use", "id": "tool_001", "name": "get_weather", "input": {...} }
]
}
而 OpenAI 的 tool calls 是消息级别的独立字段:
json
// OpenAI assistant 消息
{
"role": "assistant",
"content": "Let me check...",
"tool_calls": [
{ "id": "tool_001", "function": { "name": "get_weather", "arguments": "{...}" } }
]
}
转换逻辑:
javascript
function convertAssistantMessage(msg) {
const textBlocks = msg.content.filter(c => c.type === 'text');
const toolBlocks = msg.content.filter(c => c.type === 'tool_use');
const result = {
role: 'assistant',
content: textBlocks.map(t => t.text).join('\n') || null
};
if (toolBlocks.length > 0) {
result.tool_calls = toolBlocks.map(t => ({
id: t.id,
type: 'function',
function: {
name: t.name,
arguments: JSON.stringify(t.input)
}
}));
}
return result;
}
2.3 Stream 模式下的增量转换
流式场景更复杂。Anthropic 的 SSE 事件结构:
vbnet
event: content_block_start
data: {"type":"content_block_start","content_block":{"type":"text","text":""}}
event: content_block_delta
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}}
event: content_block_stop
data: {"type":"content_block_stop"}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}
需要实时转换为 OpenAI 的 SSE chunk 格式:
css
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"delta":{"content":"Hello"},"index":0}]}
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop","index":0}]}
这里需要一个 stream transform 来做增量映射,关键是要处理 tool_use 的增量------Anthropic 对 tool_use 是分步发送的(content_block_start → delta → stop),需要在收到完整的 input_json_delta 后才能拼接出 OpenAI 的 function.arguments。
三、模型路由:三级 Fallback
另一块核心是模型路由------用户发来的模型名怎么匹配到正确的上游。
javascript
function resolveModel(modelName) {
// 第一层:model_mappings 表精确匹配(白名单,支持自定义定价)
const exact = db.prepare(
'SELECT * FROM model_mappings WHERE model_name = ? AND enabled = 1'
).get(modelName);
if (exact) return exact;
// 第二层:前缀规则推断
const provider = inferProvider(modelName);
if (provider && hasActiveKeys(provider)) {
return buildVirtualMapping(modelName, provider);
}
// 第三层:兜底第一个启用的 provider
return fallbackToFirstEnabled();
}
function inferProvider(modelName) {
if (/^claude-/.test(modelName)) return 'anthropic';
if (/^gpt-|^o1|^o3/.test(modelName)) return 'openai';
if (/^deepseek-/.test(modelName)) return 'siliconflow';
if (/^gemini-/.test(modelName)) return 'google';
return null;
}
这样做的好处:用户用 Claude Code 发 claude-sonnet-4-20250514,即使后台没有为这个具体版本建映射,前缀匹配 claude- 也能正确路由到 Anthropic。
四、流式场景下的 Token 计费
流式响应的计费比较棘手------总 token 数只能从上游 API 的最后一条 SSE 事件里拿到。不能一边流一边扣,那样不知道要扣多少。
做法是当上游流结束后,取 usage 信息,一次性写入 credit_transactions 表,事务包裹:
javascript
async function handleStreamBilling(ctx, usage) {
const totalTokens = usage.input_tokens + usage.output_tokens;
const cost = calculateCost(ctx.mapping, totalTokens);
db.transaction(() => {
db.prepare('UPDATE users SET credits = credits - ? WHERE id = ?').run(cost, ctx.userId);
db.prepare(
'INSERT INTO credit_transactions (user_id, amount, type, tokens, model) VALUES (?, ?, ?, ?, ?)'
).run(ctx.userId, -cost, 'consume', totalTokens, ctx.model);
})();
}
五、一些踩坑记录
-
SQLite 并发 :用 WAL 模式 +
busy_timeout足够应对中转站级别的并发,不需要 Redis。单表主键查询 <1ms。 -
GFW 阻断:大陆 ECS 到 Cloudflare 边缘在某天突然全阻断(TCP + QUIC),网站 Error 1033。云厂商的自定义镜像跨地域复制救了一命------从大陆建镜像 → 复制到香港 → 创建实例,cloudflared 在香港启动即通。
-
SSH 反向隧道保活 :支付回调跨云不通时,用 systemd 管理的 SSH -R 隧道解决。
ServerAliveInterval=60防止断开,Restart=always保证崩溃自愈。
总结
做一个能同时对接多个 AI 工具的 API 网关,核心在于协议适配和路由策略的健壮性。Anthropic 协议虽然和 OpenAI 看起来相似,但在 tool use 和 stream 的实现上有大量细节需要处理。模型路由用三级 fallback 可以让系统足够灵活,既支持白名单精确控制,又能自动适配新的模型名。
本文是个人项目实践总结,欢迎讨论技术细节。