capture-api-response-test-fixture :漏出了 AI SDK 测试体系的底牌

Vercel 的 AI SDK 仓库根目录里有个 skills/ 文件夹,12 个子目录,其中 10 个的 frontmatter 上标着 internal: true。这批东西不是做给用户装进 Cursor 提效的,是 Vercel 自己喂给 AI Agent 的工作手册。今天要拆的 capture-api-response-test-fixture,就是其中之一。

先看体量。全文两百来个单词,两个 TypeScript 片段,零配置项,读一遍不超过一分钟。但它管的偏偏是 AI SDK 里最容易失控的那一块:provider 响应解析的测试。OpenAI 那个 responses 测试文件一万零七百多行,里面绝大多数断言的数据,都靠它规定的这套流程产出来。

capture-api-response-test-fixture :漏出了 AI SDK 测试体系的底牌

反差就在这。一个维护者写给 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({
    modelopenai('gpt-5-nano'),
    prompt'Invent a new holiday and describe its traditions.',
  });

  console.log(JSON.stringify(result.response.bodynull2));
});

这段脚本要跑在 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({
    modelopenai('gpt-5-nano'),
    prompt'Invent a new holiday and describe its traditions.',
    includeRawChunkstrue,
  });

  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<anyanyany>;
  filename: string;
}) {
  const rawChunksunknown[] = [];
  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 没炸。

capture-api-response-test-fixture :漏出了 AI SDK 测试体系的底牌

这里有个容易踩空的地方:chunks.txt 里存的是裸 JSON,没有 SSE 信封。协议外壳是在测试加载阶段重新拼上去的,不是捕获阶段存下来的,这里说的外壳主要是 data: 前缀和 [DONE] 哨兵,部分 provider 还要额外补事件类型字段。存裸的、用时再装,这个取舍让同一份数据能适配不同的协议方言。

回放:同一份 chunks.txt,两种 SSE 方言

回放到测试里,靠的是 @ai-sdk/test-server 起的本地 mock server。它按 URL 拦截 provider 的真实端点,把 fixture 内容按指定格式吐出来,被测的 doGenerate 或 doStream 完全感知不到自己在对着一个假端点说话。这层隔离是整个测试体系的地基。

function prepareJsonFixtureResponse(filenamestring) {
  server.urls['https://api.openai.com/v1/responses'].response = {
    type'json-value',
    bodyJSON.parse(
      fs.readFileSync(`src/responses/__fixtures__/${filename}.json`'utf8'),
    ),
  };
}

function prepareChunksFixtureResponse(filenamestring) {
  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(
  filenamestring,
  { 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 加换行,多一个字符都不写。

capture-api-response-test-fixture :漏出了 AI SDK 测试体系的底牌

看懂这张图,你就明白为什么这套流程必须分两步走。捕获阶段脚本打的是线上真 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 命名时经常搞混。

capture-api-response-test-fixture :漏出了 AI SDK 测试体系的底牌

放到具体场景里看更直观。给 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 的位置。

资源地址

类型 链接
仓库 https://github.com/vercel/ai
Skill 源文件 https://github.com/vercel/ai/blob/main/skills/capture-api-response-test-fixture/SKILL.md
Skill 目录(12 个) https://github.com/vercel/ai/tree/main/skills
OpenAI fixtures 目录 https://github.com/vercel/ai/tree/main/packages/openai/src/responses/__fixtures__
OpenAI responses 测试 https://github.com/vercel/ai/blob/main/packages/openai/src/responses/openai-responses-language-model.test.ts
saveRawChunks 源码 https://github.com/vercel/ai/blob/main/examples/ai-functions/src/lib/save-raw-chunks.ts
AI SDK 测试文档 https://ai-sdk.dev/docs/ai-sdk-core/testing
Smithery 页面 https://smithery.ai/skills/vercel/capture-api-response-test-fixture

总结

这个 skill 给出的答案很朴素:别编数据,去真跑一次,把结果原样存下来,按规矩放好。方法一点都不新,新的是它把这件事变成了 Agent 也能执行的确定性流程,而不是靠老手带新人时的口口相传。

适用边界也很清楚。你在给 AI SDK 写 provider,或者维护任何一个需要适配上游 API 的解析层,这套流程值得整套抄过去。你只是在业务代码里测自己的 prompt 和工具编排,用 MockLanguageModel 之类的假模型就够了,录 fixture 是杀鸡用牛刀。

留个问题:当仓库的内部规范越来越多地被写成 skill 而不是文档,下一个新人还会去读贡献指南吗?还是说未来的贡献流程会变成先装 skill、再让 Agent 替你摸清规矩。我自己倾向于后者,但这件事的副作用现在还看不全。

skills资源

list-npm-package-content :别把 Skill 写成操作手册,把它写成脚本

2026-8-30 11:52:13

实战分享

说一个反常识的真相,Agent如今在真实工作场景的成功率依然很低。

2026-8-17 13:03:23

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧