创建日期: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 应用时你有两种选择:
- 引官方 SDK(
openai、@anthropic-ai/sdk)——封装好、省心,但也把协议细节藏了起来; - 直接
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 带 index、finish_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_details、prompt_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.completions | Anthropic messages |
|---|---|---|
| 路径 | POST /v1/chat/completions | POST /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_tokens | input/output_tokens + 缓存读写 |
**架构上的「哲学差异」**值得单独点一句:
- OpenAI 用「角色 + 字段」,Anthropic 用「块」。OpenAI 把 system、思考、工具调用做成角色或字段(
reasoning_content、message.tool_calls);Anthropic 把所有非正文内容统一成content[]里的块(thinking是块、tool_use是块、tool_result也是块)——Agent 循环里「遍历 content 找块」比「盯着七八个字段」更统一。 - Anthropic 把多轮历史当「协议」管得更严:回填
tool_use时,必须在紧接着的一条 user 消息里给齐对应的tool_result,且要求 assistant / user 交替。OpenAI 只要求按tool_call_id对上,宽松些。(前端写 Agent 时的具体姿势见 frontend-agent 简单 Agent。)
参考:官方文档 Anthropic Messages API、OpenAI 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
上面的 interface 在 tsc 眼里很完美,但它校验不了真实 HTTP 响应——await res.json() 出来的是 any/unknown,类型系统到这就断档了。真实世界的问题是:
- 各家在
usage、顶层加私有扩展字段(刚才两家返回里都有 schema 外的 key); - 该有
signature的 thinking 块,兼容端点可能不给; content是null还是字符串、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'
}
读这个输出的三件事:
- 同一模型、同一句提问,OpenAI 侧正文走了「思考」和「正文」在
message里平铺两条字段;Anthropic 侧是content里先后两块。协议不同,产物同脑; - 两份返回都被 zod
parse通过了——要是哪家返回的形状和你 schema 写的不一样,脚本会当场报错,而不是带病运行; - usage 字段名完全两套,且都带 schema 外的私有 key(如
reasoning_tokens、service_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() |
| 2 | OpenAI 的 content 可能为 null(只想调工具时);Anthropic 的 content 可能是一串块 | schema 里 content: z.string().nullable()(OpenAI)、content 用 discriminatedUnion('type')(Anthropic) |
| 3 | Anthropic max_tokens 漏填直接 400 | 请求 schema 里设必填 |
| 4 | Anthropic 侧推理模型的 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 同时收两家 → 归一化成自己的内部消息类型」的适配层。
动手
- 把上面的
one-shot.ts存下来跑一次,删掉某段 zod(比如把finish_reason的枚举值改成错的),看它报什么错; - 在 OpenAI 请求里把
content改成多模态 parts 数组,观察返回的 shape 变化; - 给两个 schema 分别加一条「断言
usage.prompt_tokens > 0」的.refine,让校验顺带做业务检查。
自测
- OpenAI 的 system 和 Anthropic 的 system 各放在哪?谁在
messages里? - Anthropic 的返回里,一段回答为什么是
content: []数组?thinking和text是什么关系? - 同一模型的两家「思考」,OpenAI 侧藏在哪、Anthropic 侧是什么块?
- 为什么 schema 要
.passthrough()、而不是strict()?举例说明真实返回里有哪些私有字段。 max_tokens在哪个协议里是必填?为什么写 Agent 时「回填工具结果」必须紧接着上一条 assistant 消息?