
《读懂 Vercel AI SDK》系列第 2 篇。上一篇里已经把
generateText和streamText都跑通了。这一篇不重复"这俩函数是干嘛的",而是讲从 hello-world 到能上生产,中间那些你迟早会踩到的坑和用到的能力。
一、你拿到的不是一段字符串,是一个"结果对象"
新手最容易忽略的一点:generateText 返回的远不止 text。真正上生产,有两个字段几乎每次都要用。
javascript
const { text, usage, finishReason } = await generateText({
model: 'openai/gpt-5.2',
prompt: '总结这份周报......',
});
usage:你的账单
usage 里有 inputTokens / outputTokens。成本核算、限流、给用户显示额度,全靠它。别等月底看账单才发现某个功能在烧钱------在代码里就能把每次调用的 token 记下来。
finishReason:它为什么停下来
这个字段回答"模型是正常说完了,还是被打断了":
| 值 | 含义 | 你该做什么 |
|---|---|---|
stop |
正常说完 | 正常处理 |
length |
撞到 maxOutputTokens 上限被截断 |
调大上限,或提示用户"继续" |
tool-calls |
停下来要调工具 | 交给工具流程(见第 4 篇) |
content-filter |
被内容安全拦了 | 给用户友好提示 |
遇到"回答莫名其妙被截断"的 bug,第一反应就是打印 finishReason ------十有八九是 length。这是新手最常见的坑,而它在总览和 quickstart 里都不会告诉你。
二、流不止 textStream:被低估的 fullStream
quickstart 里我们用 textStream 拿纯文本。但 streamText 其实同时在吐一条信息量大得多 的流------fullStream。
textStream 只给你文本片段;fullStream 给你带类型的事件:文本增量、推理过程、工具调用、错误、结束......每一个都能单独处理。
javascript
const result = streamText({ model: 'openai/gpt-5.2', prompt: '......' });
for await (const part of result.fullStream) {
switch (part.type) {
case 'text-delta':
process.stdout.write(part.text); // 正文
break;
case 'reasoning-delta':
logThinking(part.text); // 模型的思考过程
break;
case 'tool-call':
console.log('要调工具:', part.toolName);
break;
case 'finish':
console.log('用量:', part.totalUsage); // 流结束时的汇总
break;
case 'error':
console.error(part.error);
break;
}
}
什么时候用哪个?
- 只想把字打到屏幕上 →
textStream,够简单。 - 想区分正文 / 思考过程 / 工具调用,做更精细的 UI(比如把"推理"折叠起来)→
fullStream。
这条 fullStream 的事件,和前端 useChat 收到的 parts、以及后面是一脉相承的------它们其实是同一套事件模型在不同层的样子。
三、勒住生成:别让它跑飞或卡死
面向用户的流式接口,有几个"缰绳"是生产必备的。
中断:用户点了"停止"
streamText 支持 abortSignal。用户关掉页面、点停止按钮时,你得能真的把这次生成掐掉------否则它在后台继续烧 token。
javascript
const controller = new AbortController();
const result = streamText({
model: 'openai/gpt-5.2',
prompt: '......',
abortSignal: controller.signal,
});
// 用户点停止:
controller.abort();
兜底:超时与重试
maxRetries(默认 2):模型服务抖动时自动重试,不用自己写。- 配合
abortSignal设一个整体超时,避免请求卡死拖垮你的服务。
别让错误无声消失:onError
一个很隐蔽的坑:streamText 默认会"吞掉"流式过程中的错误 ------为了不让整条流崩掉,它不会把错误 throw 到你的 for await 里,而是塞进流里。结果就是出了错你却什么都没看到。所以流式生产一定要挂 onError (顺便用 onFinish 在正常结束时记 token / 落库):
javascript
streamText({
model: 'openai/gpt-5.2',
prompt: '......',
onError: ({ error }) => log(error), // 不挂它,错误就无声消失了
onFinish: ({ usage }) => record(usage), // 正常结束:记账 / 持久化
});
(generateText 不同------它是一次性的,出错会正常 throw,直接 try/catch 即可。这个"吞错误"只发生在流式。)
控制风格:temperature 与 maxOutputTokens
temperature:低(如 0.2)=稳定、可复现,适合抽取/分类;高=发散,适合创意。maxOutputTokens:输出上限。既兜住成本,也是上面那个finishReason: 'length'的来源------两者要一起想。
四、多轮对话:把上一轮"滚"进下一轮
quickstart 的例子是单轮。真做对话,关键问题是:模型不记得上一句,上下文得你自己维护。
手动拼 messages 数组很容易出错。AI SDK 给了个省心的做法------结果里的 response.messages 就是"这一轮该追加进历史的消息",直接接上去:
javascript
let messages = [{ role: 'user', content: '你好' }];
const r1 = await generateText({ model: 'openai/gpt-5.2', messages });
messages.push(...r1.response.messages); // 把 AI 的回复滚进历史
messages.push({ role: 'user', content: '接着上面继续' });
const r2 = await generateText({ model: 'openai/gpt-5.2', messages }); // 带着完整上下文
要点:用 response.messages 累积历史,而不是自己手搓 assistant 消息------它会把工具调用等结构也一并带上,少踩很多坑。
五、把结果交出去的三种姿势
streamText 的结果不是只能 for await 自己消费,它内置了几种"交出去"的方式,对应不同场景:
| 方法 | 交给谁 | 场景 |
|---|---|---|
toUIMessageStreamResponse() |
前端 useChat |
做聊天应用(最常用) |
toTextStreamResponse() |
任意 SSE/fetch 客户端 | 只要纯文本流,不用 SDK 前端 |
for await (result.textStream) |
你自己的代码 | 后端内部消费,比如写进数据库 |
选哪个,取决于"接收方是不是 AI SDK 的前端"。做标准聊天用第一个;想让非 JS 前端也能接,就是后面的话题了。
小结
这一篇的信息,都是 quickstart 那个能跑的例子不会告诉你、但上生产会撞上的:
- 返回的是结果对象 :
usage管账单,finishReason排"回答被截断"的坑(多半是length)。 - 流有两条:
textStream(纯文本)和fullStream(带类型事件,做精细 UI 用)。 - 生产必备的缰绳:
abortSignal中断、maxRetries兜底、temperature/maxOutputTokens控风格。 - 多轮对话用
response.messages累积历史,别手搓。 - 三种交付姿势,按"接收方是谁"来选。
下一篇,我们解决另一个更实际的问题:怎么让模型别输出散文,而是稳定地吐出一段 JSON------结构化输出。