跳到主要内容

创建日期:2026-09-08 | 最近更新:2026-09-08 本篇所有请求/返回 JSON 均为本机真实调用:同一个 DeepSeek key,同一条提问,分别打 OpenAI 协议(api.deepseek.com/v1/chat/completions)与 Anthropic 协议(api.deepseek.com/anthropic/v1/messages),模型 deepseek-v4-flash。输出不是编的。

LLM 对话协议对照:OpenAI 与 Anthropic 的消息格式

一句话:OpenAI 与 Anthropic 是两家 LLM 最流行的「请求/返回协议」。同一个模型后端,可以同时披这两种皮——请求体字段名、返回的嵌套结构、system 放哪、thinking 长什么样……全都不一样。这篇文章把它们逐字段拆开对比,最后用 TS 类型 + zod 把两种返回都「定义 + 运行时校验」住,并给一个一发一收的完整 case 脚本

适用读者:前端/Node 想直接 fetch LLM 而不引 SDK 的人;想看懂各家「兼容端点」到底兼容了什么的人;写 Agent 前想先搞懂消息长什么样的人。

0. 背景:为什么值得对比两种协议

写 AI 应用时你有两种选择:

  1. 引官方 SDKopenai@anthropic-ai/sdk)——封装好、省心,但也把协议细节藏了起来;
  2. 直接 fetch——只需要一个 JSON 协议,看得懂、可控、无依赖(本博客 frontend-agent 系列 就是这么写的)。

而「直接 fetch」只依赖一件事:协议长什么样。当前事实标准基本两家:

  • OpenAI 协议,路径 /v1/chat/completions,被几十家模型厂商/网关作为「兼容底座」抄走(DeepSeek、通义、Kimi、vLLM、Ollama 默认都讲它);
  • Anthropic 协议,路径 /v1/messages,Claude 原生,DeepSeek 也开了 /anthropic 兼容端点,很多 Agent 框架默认讲它。

备注:OpenAI 官方近年在推更「新一代」的 Responses API,但行业抄的是老牌的 /v1/chat/completions——兼容端点遍地都是它,所以本文以它为准。看懂它,其它 OpenAI 系都是变体。

真实前置:下面这两个端点是同一把 key(DeepSeek sk-…)开的门:

# —— OpenAI 协议(兼容端点)——
export OPENAI_BASE_URL="https://api.deepseek.com" # 默认就是它
# 鉴权:Authorization: Bearer $KEY

# —— Anthropic 协议(兼容端点)——
export ANTHROPIC_BASE_URL="https://api.deepseek.com/anthropic"
# 鉴权:x-api-key: $KEY + anthropic-version 头

顺带一个真实小坑:同一把 key,两边的模型名还不完全通用。Anthropic 端点收 deepseek-v4-flash[1M],OpenAI 兼容端点却 400 报错,只认纯 deepseek-v4-flash。协议兼容 ≠ 模型名全兼容,写脚本时别把两边的 model 变量共用死。

1. OpenAI 协议解剖(真实返回)

一次「只问一句话」的请求体:

{"model":"deepseek-v4-flash","messages":[{"role":"system","content":"你是一个乐于助人的助手。"},{"role":"user","content":"请用一句话(不超过 40 字)介绍你自己,只输出那一句话。"}],"max_tokens":1024}

真实返回(HTTP 200):

{
"id": "bfef22ac-9822-4a6e-a798-88abca276191",
"object": "chat.completion",
"created": 1788878360,
"model": "deepseek-v4-flash",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "我是DeepSeek,一个乐于助人的AI助手,随时为你解答问题。",
"reasoning_content": "我们只需要一句话介绍自己,不超过40字。"
},
"logprobs": null,
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 108,
"completion_tokens": 28,
"total_tokens": 136,
"prompt_tokens_details": { "cached_tokens": 0 },
"completion_tokens_details": { "reasoning_tokens": 10 },
"prompt_cache_hit_tokens": 0,
"prompt_cache_miss_tokens": 108
},
"system_fingerprint": "a26a7955944dc5c60445bff77fac9c8e"
}

逐字段怎么看

顶层字段含义
object: "chat.completion"协议自报家门,客户端可用它判断响应种类
choices: [ … ]即使只问一条也是数组。OpenAI 语义上支持一次出多个候选(n 参数),所以答案被套进数组,每个 choice 带 indexfinish_reason
choices[0].message.content模型正文(字符串);可能为 null(比如这次只想调用工具没说话时)
choices[0].message.role恒为 assistant
choices[0].message.reasoning_content推理模型的「思考」——注意:这不是官方 OpenAI schema,是 DeepSeek 等兼容端点加的扩展字段(OpenAI 官方不透出思维链)。收文本时若不需要思考就忽略它
choices[0].finish_reason结束原因:stop(正常完)、length(顶到 token 上限)、tool_calls(想调工具)、content_filter
usage计费/统计。OpenAI 叫 prompt_tokens / completion_tokens / total_tokens;后面 prompt_tokens_detailsprompt_cache_hit_tokens 之类全是各家私有扩展,schema 别写死

请求侧要点:

  • system 是一条普通消息:把 role: "system" 的消息放 messages 最前面即可(OpenAI 新加 developer 角色同位置);
  • 消息就是 { role, content: string } 数组,人话就是「这段历史对话」。多模态时才把 content 换成 parts 数组;
  • messages 里共四种角色:system / user / assistant / tool(工具回填用的角色,见下)。

2. Anthropic 协议解剖(真实返回)

同一条提问,同一把 key,走 Messages API:

{"model":"deepseek-v4-flash[1M]","max_tokens":1024,"system":"你是一个乐于助人的助手。","messages":[{"role":"user","content":"请用一句话(不超过 40 字)介绍你自己,只输出那一句话。"}]}

真实返回(HTTP 200):

{
"id": "ee56937d-2d0a-417f-ad9f-2092944df978",
"type": "message",
"role": "assistant",
"model": "deepseek-v4-flash",
"content": [
{
"type": "thinking",
"thinking": "我们要求用一句话不超过40字介绍自己,只输出那一句话。需要简洁。例如“我是乐于助人的AI助手,擅长解答问题。”字数?数一下……",
"signature": "ee56937d-2d0a-417f-ad9f-2092944df978"
},
{
"type": "text",
"text": "我是乐于助人的AI助手,擅长解答各类问题。"
}
],
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {
"input_tokens": 108,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 205,
"service_tier": "standard"
}
}

上面两段 JSON 取自同一次真实调用;仅把 thinking 的文本做了删节(真实版本极长,格式与字段不删)。thinking 原文与 text 随每次调用微变,§5 脚本输出是另一次运行,文字不同属正常。

逐字段怎么看

字段含义
type: "message"自报家门(对应上面的 object
content: [ … ]「内容块数组」,这是 Anthropic 和 OpenAI 最根本的区别之一:一段回复可以是一串不同类型的块串起来
content[0].type: "thinking"推理模型的思考块,官方一等公民,带 thinking 文本和 signature(Anthropic 官方必带签名;个别兼容端点会缺)
content[1].type: "text"真正给用户的正文块
stop_reason: "end_turn"结束原因在顶层(OpenAI 把它嵌在 choice 里):end_turn / max_tokens / tool_use / stop_sequence
stop_sequence命中自定义停止串时为该串,否则 null
usage名字换成 input_tokens / output_tokens / cache_creation_input_tokens / cache_read_input_tokens(缓存读写拆开算)

请求侧要点:

  • system 抽到顶层独立字段,不在 messages 里;
  • messages只有 user / assistant 两种角色(没有 system/tool 角色),且要求交替出现;
  • max_tokens 必填,没有默认值——忘了填直接 400;
  • 单条消息的 content 可以是字符串,也可以是内容块数组text / thinking / tool_use …)。工具回填就是「往 user 消息的 content 块数组里塞 tool_result」。

3. 一张表看清差异

维度OpenAI chat.completionsAnthropic messages
路径POST /v1/chat/completionsPOST /v1/messages
鉴权头Authorization: Bearer <key>x-api-key: <key> + anthropic-version
system 放哪messages[0] 里一条 role: system 消息顶层独立字段 system
角色集合system / user / assistant / tool(另有 developer只有 user / assistant
单条消息 content通常是字符串;可换成多模态 parts字符串内容块数组
max_tokens可选必填
回答嵌套顶层 choices[](单条也包一层),结束原因在 choice.finish_reason顶层 stop_reason;正文是 content[] 块数组
推理模型的思考厂商扩展 message.reasoning_content(官方 schema 不透出)规整的块 { type: "thinking", thinking, signature }
工具声明tools: [{ type: "function", function: { name, parameters } }]tools: [{ name, description, input_schema }]
工具调用回填补一条 assistant(带 tool_calls)→ 再补一条 role: "tool" 消息(tool_call_id 对上)补一条 assistant → 补一条 user,其 content 里塞 tool_result 块(tool_use_id 对上)
usage 字段名prompt/completion/total_tokensinput/output_tokens + 缓存读写

**架构上的「哲学差异」**值得单独点一句:

  1. OpenAI 用「角色 + 字段」,Anthropic 用「块」。OpenAI 把 system、思考、工具调用做成角色或字段(reasoning_contentmessage.tool_calls);Anthropic 把所有非正文内容统一成 content[] 里的块(thinking 是块、tool_use 是块、tool_result 也是块)——Agent 循环里「遍历 content 找块」比「盯着七八个字段」更统一。
  2. Anthropic 把多轮历史当「协议」管得更严:回填 tool_use 时,必须在紧接着的一条 user 消息里给齐对应的 tool_result,且要求 assistant / user 交替。OpenAI 只要求按 tool_call_id 对上,宽松些。(前端写 Agent 时的具体姿势见 frontend-agent 简单 Agent。)

参考:官方文档 Anthropic Messages APIOpenAI Chat Completions、DeepSeek 兼容性说明(api-docs.deepseek.com)。

4. 用 TS 定义两种协议的类型

协议摸清了,接着用 TypeScript 把形状「钉死」。两种做法互补:

  • 手写 interface:只有编译期意义,直观好读,适合当文档;
  • 用 zod 写 schema:一份声明同时给出「编译期类型」「运行时校验」——HTTP 返回是运行时才有的东西,类型系统管不到它,所以校验真实响应正是 zod 的地盘(zod 入门看本站 zod 栏目,v4)。

4.1 先来一份「文档版」纯类型(编译期)

// ==================== OpenAI 协议 ====================
type OpenAIRole = 'system' | 'developer' | 'user' | 'assistant' | 'tool';

interface OpenAIMessage {
role: OpenAIRole;
content: string; // 多模态时这里可换成 parts 数组
// 回填工具结果的那条 role:'tool' 消息需要:
tool_call_id?: string;
}

interface OpenAIChatRequest {
model: string;
messages: OpenAIMessage[]; // system 也塞在里面(约定放最前)
max_tokens?: number;
temperature?: number;
tools?: Array<{ // 可选:声明可用工具
type: 'function';
function: { name: string; description?: string; parameters: object };
}>;
}

interface OpenAIChatCompletion {
id: string;
object: 'chat.completion';
created: number;
model: string;
choices: Array<{
index: number;
finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter' | null;
message: {
role: 'assistant';
content: string | null;
reasoning_content?: string | null; // DeepSeek 等兼容端点的思考(官方 schema 没有)
};
}>;
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}

// ==================== Anthropic 协议 ====================
type AnthropicRole = 'user' | 'assistant'; // 就这两种,system 在顶层

type AnthropicContentBlock =
| { type: 'text'; text: string }
| { type: 'thinking'; thinking: string; signature: string } // 推理模型思考块
| { type: 'tool_use'; id: string; name: string; input: unknown };

interface AnthropicMessageRequest {
model: string;
max_tokens: number; // 必填!没有默认值
system?: string | Array<{ type: 'text'; text: string }>;
messages: Array<{ // user/assistant 交替
role: AnthropicRole;
content: string | AnthropicContentBlock[]; // tool_result 也是塞进这里的块
}>;
tools?: Array<{ name: string; description?: string; input_schema: object }>;
}

interface AnthropicMessageResponse {
id: string;
type: 'message';
role: 'assistant';
model: string;
content: Array<
| { type: 'text'; text: string }
| { type: 'thinking'; thinking: string; signature: string }
| { type: 'tool_use'; id: string; name: string; input: unknown }
>;
stop_reason: 'end_turn' | 'max_tokens' | 'stop_sequence' | 'tool_use' | null;
stop_sequence: string | null;
usage: {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
};
}

4.2 为什么还需要 zod

上面的 interfacetsc 眼里很完美,但它校验不了真实 HTTP 响应——await res.json() 出来的是 any/unknown,类型系统到这就断档了。真实世界的问题是:

  • 各家在 usage、顶层加私有扩展字段(刚才两家返回里都有 schema 外的 key);
  • 该有 signature 的 thinking 块,兼容端点可能不给;
  • contentnull 还是字符串、reasoning_content 存不存在,都随模型变。

zod 的思路是:同一份形状,既能 infer 出编译期类型,又能在运行时 parse。只要写一份 schema,parse 通过 = 这份真实数据完全符合声明;不通过会告诉你具体哪个字段塌了。真机跑一遍就是最好的证明——下面整个 case 脚本里,两家的返回都被 zod parse 过才继续。

5. 完整 case:一发一收(TS + zod 真跑)

下面这个 one-shot.ts完整、可直接跑的:同一句提问、同一个 key,各打一发,拿到后用 zod 校验、再抽出正文。只依赖 Node + zod

# 准备(node >= 22.6 原生跑 TS,不需要 ts-node/tsx)
npm i zod
# 配 key(DeepSeek 一把钥匙开两扇门)
export ANTHROPIC_AUTH_TOKEN="sk-…" # OpenAI 侧没配 key 时会复用这把
node one-shot.ts
// one-shot.ts —— 一发一收:同 key 双协议,zod 把关
import { z } from 'zod';

// 0. 配置:Anthropic 侧用 ANTHROPIC_*;OpenAI 侧没配时复用同一个 key
const cfg = {
openai: {
base: (process.env.OPENAI_BASE_URL ?? 'https://api.deepseek.com').replace(/\/+$/, ''),
key: process.env.OPENAI_API_KEY ?? process.env.ANTHROPIC_AUTH_TOKEN ?? '',
// OpenAI 兼容端点只认不带 [1M] 的纯模型名;Anthropic 端点反而收带变体名
model: process.env.OPENAI_MODEL ?? 'deepseek-v4-flash',
},
anthropic: {
base: (process.env.ANTHROPIC_BASE_URL ?? 'https://api.deepseek.com/anthropic').replace(/\/+$/, ''),
key: process.env.ANTHROPIC_AUTH_TOKEN ?? '',
model: process.env.ANTHROPIC_MODEL ?? 'deepseek-v4-flash',
},
};
if (!cfg.openai.key) { console.error('缺少 key:请设 ANTHROPIC_AUTH_TOKEN(或 OPENAI_API_KEY)'); process.exit(1); }

// 1. zod schema = 类型 + 运行时校验二合一
export const OpenAIResponseSchema = z.object({
id: z.string(),
object: z.literal('chat.completion'),
created: z.number(),
model: z.string(),
choices: z.array(z.object({
index: z.number(),
message: z.object({
role: z.literal('assistant'),
content: z.string().nullable(),
reasoning_content: z.string().nullable().optional(), // 兼容端点扩展
refusal: z.string().nullable().optional(),
}),
finish_reason: z.enum(['stop', 'length', 'tool_calls', 'content_filter', 'function_call']).nullable(),
logprobs: z.unknown().nullable(),
})),
usage: z.object({
prompt_tokens: z.number(),
completion_tokens: z.number(),
total_tokens: z.number(),
prompt_tokens_details: z.object({ cached_tokens: z.number() }).optional(),
completion_tokens_details: z.object({ reasoning_tokens: z.number() }).optional(),
}).passthrough(),
system_fingerprint: z.string().optional(),
}).passthrough(); // 保留未知私有字段,只校验关键形状
export type OpenAIResponse = z.infer<typeof OpenAIResponseSchema>;

export const AnthropicContentSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('text'), text: z.string() }),
// 官方 thinking 必带 signature,兼容端点个别会缺,故 optional
z.object({ type: z.literal('thinking'), thinking: z.string(), signature: z.string().optional() }),
z.object({ type: z.literal('tool_use'), id: z.string(), name: z.string(), input: z.unknown() }),
]);
export type AnthropicContent = z.infer<typeof AnthropicContentSchema>;

export const AnthropicResponseSchema = z.object({
id: z.string(),
type: z.literal('message'),
role: z.literal('assistant'),
model: z.string(),
content: z.array(AnthropicContentSchema),
stop_reason: z.enum(['end_turn', 'max_tokens', 'stop_sequence', 'tool_use', 'pause_turn']).nullable(),
stop_sequence: z.string().nullable(),
usage: z.object({
input_tokens: z.number(),
output_tokens: z.number(),
cache_creation_input_tokens: z.number().optional(),
cache_read_input_tokens: z.number().optional(),
}).passthrough(),
}).passthrough();
export type AnthropicResponse = z.infer<typeof AnthropicResponseSchema>;

// 2. 两个「发送一次」,都是 parse 之后才返回
async function sendOpenAI(messages: object) {
const res = await fetch(`${cfg.openai.base}/v1/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.openai.key}` },
body: JSON.stringify(messages),
});
if (!res.ok) throw new Error(`OpenAI HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
return OpenAIResponseSchema.parse(await res.json()); // ← 运行时校验
}

async function sendAnthropic(body: object) {
const res = await fetch(`${cfg.anthropic.base}/v1/messages`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': cfg.anthropic.key,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Anthropic HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
return AnthropicResponseSchema.parse(await res.json()); // ← 运行时校验
}

// 3. 同一句提问,两端各打一发
const Q = '请用一句话(不超过 40 字)介绍你自己,只输出那一句话。';
const system = '你是一个乐于助人的助手。';

const oai = await sendOpenAI({ // OpenAI:system 是 messages[0]
model: cfg.openai.model,
messages: [{ role: 'system', content: system }, { role: 'user', content: Q }],
max_tokens: 1024,
});
const ant = await sendAnthropic({ // Anthropic:system 在顶层,max_tokens 必填
model: cfg.anthropic.model,
max_tokens: 1024,
system,
messages: [{ role: 'user', content: Q }],
});

console.log('== OpenAI 协议 ==');
console.log('正文 =', oai.choices[0].message.content);
console.log('思考(reasoning_content) =', oai.choices[0].message.reasoning_content ?? '(无)');
console.log('finish_reason =', oai.choices[0].finish_reason, '| usage =', oai.usage);

console.log('\n== Anthropic 协议 ==');
for (const b of ant.content) {
if (b.type === 'text') console.log('text 块 =', b.text);
if (b.type === 'thinking') console.log('thinking 块 =', String(b.thinking).slice(0, 60) + '…');
}
console.log('stop_reason =', ant.stop_reason, '| usage =', ant.usage);

本机真实输出node one-shot.ts,只打印上面两段 JSON 的关键字段):

== OpenAI 协议 ==
正文 = 我是AI助手,乐于解答问题、提供信息,随时为你服务。
思考(reasoning_content) = 我们只需要一句话介绍,不超过40字。输出简洁。
finish_reason = stop | usage = {
prompt_tokens: 108,
completion_tokens: 28,
total_tokens: 136,
prompt_tokens_details: { cached_tokens: 0 },
completion_tokens_details: { reasoning_tokens: 12 },
prompt_cache_hit_tokens: 0,
prompt_cache_miss_tokens: 108
}

== Anthropic 协议 ==
thinking 块 = 我们要求用一句话不超过40字介绍自己,只输出那句话。作为助手,可以说“我是乐于助人的AI助手,随时为你解答问题。”计数字…
text 块 = 我是乐于助人的AI助手,随时为你答疑解惑。
stop_reason = end_turn | usage = {
input_tokens: 108,
output_tokens: 78,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
service_tier: 'standard'
}

读这个输出的三件事:

  1. 同一模型、同一句提问,OpenAI 侧正文走了「思考」和「正文」在 message 里平铺两条字段;Anthropic 侧是 content 里先后两块。协议不同,产物同脑;
  2. 两份返回都被 zod parse 通过了——要是哪家返回的形状和你 schema 写的不一样,脚本会当场报错,而不是带病运行;
  3. usage 字段名完全两套,且都带 schema 外的私有 key(如 reasoning_tokensservice_tier)——这就是 schema 加 .passthrough()、把私有字段标 optional() 的原因:校验关键形状,宽容各家扩展

实测取自本地哪份配置? 上面的脚本一个 key 都不填就能跑,因为它直接吃本机 Claude Code 的配置(~/.claude/settings.json,key 打码)——Claude Code 本体就跑在 DeepSeek 的 Anthropic 端点上;同一把 key 也通 OpenAI 端点(同域 /v1/chat/completions + 纯模型名 deepseek-v4-flash):

{
"env": {
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
"ANTHROPIC_MODEL": "deepseek-v4-flash[1M]",
"ANTHROPIC_AUTH_TOKEN": "sk-****(打码)"
}
}

复跑一次,本次输出如下(模型文字随每次调用微变,结构不变):

== OpenAI 协议 ==
正文 = 我是一个专注、高效的AI助手,乐于解答各类问题,提供准确信息。
思考(reasoning_content) = 我们只需要一句话介绍自己,不超过40字。简洁。
finish_reason = stop | usage = {
prompt_tokens: 108,
completion_tokens: 29,
total_tokens: 137,
prompt_tokens_details: { cached_tokens: 0 },
completion_tokens_details: { reasoning_tokens: 12 },
prompt_cache_hit_tokens: 0,
prompt_cache_miss_tokens: 108
}

== Anthropic 协议 ==
thinking 块 = 我们要求用一句话不超过40字介绍自己,只输出那一句话。可以简单说“我是一个乐于助人的AI助手,随时为你解答问题。”检查字…
text 块 = 我是一个乐于助人的AI助手,随时为你解答各种问题。
stop_reason = end_turn | usage = {
input_tokens: 108,
output_tokens: 70,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
service_tier: 'standard'
}

6. 常见坑清单

#解法
1返回里总有 schema 外的私有字段,用 strict() 会把真实返回 reject对顶层与 usage 用 .passthrough(),把可选的厂商扩展标 optional()
2OpenAI 的 content 可能为 null(只想调工具时);Anthropic 的 content 可能是一串块schema 里 content: z.string().nullable()(OpenAI)、contentdiscriminatedUnion('type')(Anthropic)
3Anthropic max_tokens 漏填直接 400请求 schema 里设必填
4Anthropic 侧推理模型的 signature 官方必带、兼容端点可能缺schema 里 signature 设为 optional,取值时按块类型过滤
5同一个 key,模型名两套端点可能不一致(deepseek-v4-flash[1M] vs deepseek-v4-flash两边的 model 分开配,别共用死一个变量
6把 zod schema 里的「类型」和手写 interface 重复维护,两边迟早漂移只写一份 zod schema,类型用 z.infer 派生,interface 只当文档

7. 关联与下一步

  • 本仓库 zod 栏目:zod v4 语法与本篇的 .passthrough() / discriminatedUnion / z.infer 出处;
  • frontend-agent 系列:基于 Anthropic 协议手写工具调用循环——把本篇「一发一收」升级成「多轮 + 工具回填」;
  • 已写:流式(SSE)与打字机——两种协议怎么把内容逐 token 推给你(OpenAI 的 data: 增量 / Anthropic 的 content_block_delta),含可跑打字机脚本;再往后可写「用 zod 同时收两家 → 归一化成自己的内部消息类型」的适配层。

动手

  1. 把上面的 one-shot.ts 存下来跑一次,删掉某段 zod(比如把 finish_reason 的枚举值改成错的),看它报什么错;
  2. 在 OpenAI 请求里把 content 改成多模态 parts 数组,观察返回的 shape 变化;
  3. 给两个 schema 分别加一条「断言 usage.prompt_tokens > 0」的 .refine,让校验顺带做业务检查。

自测

  1. OpenAI 的 system 和 Anthropic 的 system 各放在哪?谁在 messages 里?
  2. Anthropic 的返回里,一段回答为什么是 content: [] 数组?thinkingtext 是什么关系?
  3. 同一模型的两家「思考」,OpenAI 侧藏在哪、Anthropic 侧是什么块?
  4. 为什么 schema 要 .passthrough()、而不是 strict()?举例说明真实返回里有哪些私有字段。
  5. max_tokens 在哪个协议里是必填?为什么写 Agent 时「回填工具结果」必须紧接着上一条 assistant 消息?