Vercel 的 AI SDK 仓库根目录里有个
skills/文件夹,12 个子目录,其中 10 个的 frontmatter 上标着internal: true。这批东西不是做给用户装进 Cursor 提效的,是 Vercel 自己喂给 AI Agent 的工作手册。今天要拆的capture-api-response-test-fixture,就是其中之一。
先看体量。全文两百来个单词,两个 TypeScript 片段,零配置项,读一遍不超过一分钟。但它管的偏偏是 AI SDK 里最容易失控的那一块:provider 响应解析的测试。OpenAI 那个 responses 测试文件一万零七百多行,里面绝大多数断言的数据,都靠它规定的这套流程产出来。

反差就在这。一个维护者写给 Agent 看的短文档,居然是整个仓库测试数据供应链的上游入口。我一开始以为这只是把贡献文档里那段话复制了一遍,翻完仓库才发现,它压缩掉的恰恰是最值钱的部分:那些从没写进文档、只活在老贡献者脑子里的约定。
整条链路其实就三步。写个脚本真调一次 provider,把响应原样落盘,再搬到 __fixtures__ 目录里按规矩改名。同步调用和流式调用走两条不同的路,前者落一个 JSON,后者落一个每行一条 JSON 的文本文件,剩下的事交给测试里的 mock server 去回放。
两条捕获路径,一套命名规矩
先说同步那条路。generateText 打到 provider,返回的 result.response.body 就是未经加工的上游原始响应体。SKILL.md 让你把脚本放进 examples/ai-functions/src/generate-text/<provider>/,把这段原始响应打印到终端,再手动复制进新的 fixture 文件。
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
import { run } from '../../lib/run';
run(async () => {
const result = await generateText({
model: openai('gpt-5-nano'),
prompt: 'Invent a new holiday and describe its traditions.',
});
console.log(JSON.stringify(result.response.body, null, 2));
});
这段脚本要跑在 examples/ai-functions 目录下,run 是那个目录自带的 src/lib/run.ts 封装,负责兜住异常和退出码。关键是它打印的是 response.body 而不是 result.text,前者是 provider 吐出来的原始 JSON,后者已经被 SDK 解析过一轮,拿后者当 fixture 等于把被测对象绕过去了。
流式那条路麻烦得多。streamText 的响应是一条 SSE 流,chunk 一个接一个飘过来,你没法在终端里复制粘贴。SKILL.md 给的解法是打开 includeRawChunks: true,再配一个专门的 saveRawChunks helper,让它把每帧原始数据逐行写进文件。
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
import { run } from '../../lib/run';
import { saveRawChunks } from '../../lib/save-raw-chunks';
run(async () => {
const result = streamText({
model: openai('gpt-5-nano'),
prompt: 'Invent a new holiday and describe its traditions.',
includeRawChunks: true,
});
await saveRawChunks({ result, filename: 'openai-gpt-5-nano' });
});
includeRawChunks 是 AI SDK 5 就引入的开关,打开后 result.stream 里会混入 type: 'raw' 的事件,每个事件挂着 provider 原封不动的那一帧。默认关闭,因为对业务代码来说这是噪音;对录 fixture 的人来说,这恰恰是唯一想要的东西。
跑法是 pnpm tsx src/stream-text/<provider>/<script-name>.ts,产物落在 examples/ai-functions/output/<filename>.chunks.txt。SKILL.md 在这里只说了一句“复制到你的 fixtures 目录并改名”,没说改什么名。被压缩掉的 tribal knowledge 就藏在这句话背后,得回仓库里找答案。
export async function saveRawChunks({
result,
filename,
}: {
result: StreamTextResult<any, any, any>;
filename: string;
}) {
const rawChunks: unknown[] = [];
for await (const chunk of result.stream) {
if (chunk.type === 'raw') {
rawChunks.push(chunk.rawValue);
}
}
fs.writeFileSync(
`output/${filename}.chunks.txt`,
rawChunks.map(chunk => JSON.stringify(chunk)).join('\n'),
);
}
看 helper 的实现比读文档更有说服力。它遍历 result.stream,只捞 type === 'raw' 的帧,序列化后用换行符拼接,写进 output/。整个函数二十行不到,连错误处理和目录创建都没有,它假设你已经在正确的 cwd,假设 output 目录存在,假设 provider 没炸。

这里有个容易踩空的地方:chunks.txt 里存的是裸 JSON,没有 SSE 信封。协议外壳是在测试加载阶段重新拼上去的,不是捕获阶段存下来的,这里说的外壳主要是 data: 前缀和 [DONE] 哨兵,部分 provider 还要额外补事件类型字段。存裸的、用时再装,这个取舍让同一份数据能适配不同的协议方言。
回放:同一份 chunks.txt,两种 SSE 方言
回放到测试里,靠的是 @ai-sdk/test-server 起的本地 mock server。它按 URL 拦截 provider 的真实端点,把 fixture 内容按指定格式吐出来,被测的 doGenerate 或 doStream 完全感知不到自己在对着一个假端点说话。这层隔离是整个测试体系的地基。
function prepareJsonFixtureResponse(filename: string) {
server.urls['https://api.openai.com/v1/responses'].response = {
type: 'json-value',
body: JSON.parse(
fs.readFileSync(`src/responses/__fixtures__/${filename}.json`, 'utf8'),
),
};
}
function prepareChunksFixtureResponse(filename: string) {
const chunks = fs
.readFileSync(`src/responses/__fixtures__/${filename}.chunks.txt`, 'utf8')
.split('\n')
.filter(line => line.trim().length > 0)
.map(line => `data: ${line}\n\n`);
chunks.push('data: [DONE]\n\n');
server.urls['https://api.openai.com/v1/responses'].response = {
type: 'stream-chunks',
chunks,
};
}
这是 OpenAI responses 测试里的两个 helper。前者读 .json 后直接 JSON.parse 塞给 json-value 类型的响应,服务同步路径;后者把 .chunks.txt 按行切开,逐行套上 data: 前缀,末尾补一个 data: [DONE],交给 stream-chunks。
换成 cohere 就不一样了。它的 provider 走的是带 event 字段的 SSE 变体,加载函数得先把每行 JSON.parse 回来,取出 type 字段拼成 event: 行,再挂 data 行。同一个 chunks.txt,在两个包里被还原成两种不同的网络报文。
function prepareChunksFixtureResponse(
filename: string,
{ withEvent }: { withEvent?: boolean } = {},
) {
const chunks = fs
.readFileSync(`src/__fixtures__/${filename}.chunks.txt`, 'utf8')
.split('\n')
.filter(line => line.trim() !== '')
.map(line => {
const parsed = JSON.parse(line);
return `event: ${parsed.type}\ndata: ${line}\n\n`;
});
server.urls['https://api.cohere.com/v2/chat'].response = {
type: 'stream-chunks',
chunks,
};
}
我把这两个 loader 摆在一起看了很久。它们证明了一件事:fixture 记的是语义,不是字节。你存的是 provider 想表达的那一帧内容,至于这帧内容用哪种协议格式送达,决定权留给消费方。这也是为什么 saveRawChunks 只做 JSON.stringify 加换行,多一个字符都不写。

看懂这张图,你就明白为什么这套流程必须分两步走。捕获阶段脚本打的是线上真 API,回放阶段测试读的是本地磁盘。中间那个“人工复制并改名”的动作是刻意的接缝,它给了人一次检查的机会:这一帧到底值不值得进版本库。
命名约定:文档没写,仓库写了
两条路径的差异用表格摊开更清楚。别看都是录 fixture,落盘格式、回放方式、代码入口几乎没有一处重合,唯一共享的是最后那个手工搬运的动作。
| 维度 | generateText(doGenerate) | streamText(doStream) |
|---|---|---|
| 脚本位置 | src/generate-text/<provider>/ |
src/stream-text/<provider>/ |
| 关键开关 | 无 | includeRawChunks: true |
| 捕获方式 | console.log(result.response.body) |
saveRawChunks({ result, filename }) |
| 中间产物 | 终端输出 | output/<name>.chunks.txt |
| 归档格式 | <feature>.<n>.json |
<feature>.<n>.chunks.txt |
| 回放 helper | prepareJsonFixtureResponse |
prepareChunksFixtureResponse |
真正难的是命名。SKILL.md 只甩了句“参考 packages/openai/src/responses/__fixtures__ 的文件名”,把这个目录列出来,规律其实一目了然,就是 <功能名>.<序号>.<扩展名>,同步和流式成对出现,扩展名分别是 .json 和 .chunks.txt。
openai-mcp-tool-approval.1.json
openai-mcp-tool-approval.1.chunks.txt
openai-mcp-tool-approval.2.json
openai-mcp-tool-approval.2.chunks.txt
openai-mcp-tool-approval.3.chunks.txt
openai-mcp-tool-approval.4.chunks.txt
github-copilot-id-rotation.1.chunks.txt
reasoning-model-temperature-error.json
序号是这套约定里最聪明的地方。一个功能点往往不是一次调用能测完的,多轮对话、工具审批、断线重连,每一轮都是独立的一帧序列。openai-mcp-tool-approval 就铺到了 .1 到 .4,四个文件串起一整条审批链,测试按序号依次回放就能模拟连续交互。
还有个边界情况值得记一下:不是所有文件都带序号。reasoning-model-temperature-error.json 这种错误场景的 fixture 只有一个,因为它在整条链路里只会触发一次。序号表达的是“同一场景的第 N 次交互”,不是“第 N 个文件”,这个区别在给新 fixture 命名时经常搞混。

放到具体场景里看更直观。给 OpenAI 加一个新的 hosted tool,流程是先写脚本打一次真 API,拿到 JSON 存成 openai-xxx-tool.1.json,再写流式版本拿 .1.chunks.txt,最后在测试文件里用两个 prepare helper 各挂一条断言。整个过程不碰一行 mock 数据。
反过来,那些不适合真跑的场景它就帮不上忙。需要特定错误码、需要超时、需要 provider 侧限流的用例,fixture 只能靠人手改写。SKILL.md 也承认大响应可以裁剪,但要求“不改变语义”,这个度怎么把握,它没给判据。embedding 向量是典型例子,几千维的数组全存进版本库毫无意义,截到每个向量留 5 个值是社区里通行的做法。
它真正值钱的地方不是代码
回头看这个 skill,代码部分几乎不值得讨论。真正值钱的是它把一段 tribal knowledge 显式化了,这些规矩过去只活在维护者的 review 评论里:
-
fixture 该放在哪个 __fixtures__目录 -
文件名该按什么格式命名 -
由哪个 example 脚本负责生成 -
生成完往哪搬、改名成什么样
现在它们被压缩成 250 个单词,随时能喂给一个从没贡献过这个仓库的 Agent,不用等人来带。
这背后是个挺激进的判断:开源项目的贡献门槛,正在从“读完贡献指南”变成“装好 skill”。Vercel 仓库里那 10 个 internal skill 几乎把维护者的日常动作全包了:
-
新增 provider 包 -
新增 harness 适配包 -
批量更新模型 ID -
大版本发布模式(major-version-mode) -
写架构决策记录(ADR)
新人带一个 Agent 进来,第一周就能干原来要磨一个月才上手的活。
不过别急着去装。这个 skill 的 frontmatter 写着 internal: true,它的每段代码都依赖仓库内的 run.ts 和 save-raw-chunks.ts,脱离 vercel/ai 的目录结构就是两截废代码。它真正可迁移的产出是那套约定:真实优先、裸数据落盘、协议外壳留给消费方。
我最认同的是“真实优先”这条。用假数据测解析器,等于拿自己写的答案批自己的卷子,provider 什么时候在多字节字符上把 chunk 切成了两半、什么时候在 usage 字段里塞了 null,你一个都测不出来。fixture 是时间胶囊,锁的是历史上那一刻 provider 的真实行为。
代价也得认。真实响应会过期,provider 一改格式,旧 fixture 就变成了一份记录着“曾经如此”的档案而不是现状。这没有银弹,只能靠定期重录。SKILL.md 对版本策略和失效检测只字未提,是它最明显的缺口,也许也是留给下一个 skill 的位置。
资源地址
总结
这个 skill 给出的答案很朴素:别编数据,去真跑一次,把结果原样存下来,按规矩放好。方法一点都不新,新的是它把这件事变成了 Agent 也能执行的确定性流程,而不是靠老手带新人时的口口相传。
适用边界也很清楚。你在给 AI SDK 写 provider,或者维护任何一个需要适配上游 API 的解析层,这套流程值得整套抄过去。你只是在业务代码里测自己的 prompt 和工具编排,用 MockLanguageModel 之类的假模型就够了,录 fixture 是杀鸡用牛刀。
留个问题:当仓库的内部规范越来越多地被写成 skill 而不是文档,下一个新人还会去读贡献指南吗?还是说未来的贡献流程会变成先装 skill、再让 Agent 替你摸清规矩。我自己倾向于后者,但这件事的副作用现在还看不全。

