文本生成的进阶:generateText / streamText 里迟早会撞上的东西

《读懂 Vercel AI SDK》系列第 2 篇。上一篇里已经把 generateTextstreamText 都跑通了。这一篇不重复"这俩函数是干嘛的",而是讲从 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 即可。这个"吞错误"只发生在流式。)

控制风格:temperaturemaxOutputTokens

  • 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------结构化输出。


参考:AI SDK Core · Generating Text

相关推荐
渣波1 小时前
深度解析工厂模式:从蜜雪冰城到 NestFactory,彻底搞懂“创建与使用分离”
前端·javascript
蔓越莓1 小时前
打包工具:编译器ESBuild
前端·面试
用户921080262861 小时前
Bubble 的 loading 和 typing:AI 回复生成中的交互处理
前端
Mr_liu_6661 小时前
Claude非专业入门实战笔记(附录):关闭Telemetry-遥测,消除无用网络请求与日志噪音
linux·笔记·ubuntu·ai编程
江小渔1 小时前
训练工程与训练平台入门33 追踪一次 TrainJob 的完整生命周期
后端
SamChan901 小时前
用Playwright端到端测试PDF翻译功能:Web自动化测试实战
前端·python·ai·pdf·wpf
无糖可可果1 小时前
NestJS 学习分享:从工厂模式到企业级后端开发
后端
平生不晚1 小时前
在 SVG 体系里画一条任意的线
前端·算法
Justin3go1 小时前
什么是 DeepSeek-Harness?完整介绍
ai编程·deepseek