第二个 AI 调用——invoke 阻塞式与 stream 流式生成

接上篇《第一个 AI 调用------TypeScript 工程初始化与配置管理》,本篇在已经能成功调用大模型的基础上,深入对比 LangChain.js 里两种最常用的调用方式:invoke(阻塞式)stream(流式)。读完你就能根据场景选对姿势,并避开几个真实的坑。

一、回顾:模型是怎么初始化的

无论用哪种方式,第一步都是创建一个 ChatOpenAI 实例(以 DeepSeek 为例):

ts 复制代码
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";

// 从 .env 读取配置:NANA_MODEL / NANA_API_KEY / NANA_API_BASE_URL
const getEnv = (key: string) => process.env[key] || "";

const llm = new ChatOpenAI({
  model: getEnv("NANA_MODEL"),            // 例如 deepseek-chat
  apiKey: getEnv("NANA_API_KEY"),        // sk-xxx
  configuration: {
    baseURL: getEnv("NANA_API_BASE_URL"), // https://api.deepseek.com/v1
  },
});

.env 关键配置(注意 baseURL 只写到 /v1,SDK 会自动拼接 /chat/completions):

env 复制代码
NANA_API_KEY=sk-xxxx
NANA_MODEL=deepseek-chat
NANA_API_BASE_URL=https://api.deepseek.com/v1

环境变量、依赖安装、Prettier 保存格式化等前置步骤见上篇,本文不再展开。

二、invoke:阻塞式调用

invoke 是最直观的调用方式------把整个 Prompt 丢进去,等模型把全部内容生成完,一次性返回结果

ts 复制代码
const invoke = async (prompt: string) => {
  const res = await llm.invoke(prompt);
  console.log(res.content);
};

invoke("写一首关于AI的诗");

发生了什么

  1. 请求发出,客户端进入等待
  2. 模型在服务器侧逐 token 生成,但服务端会攒齐全部 token 才一次性返回 HTTP 响应
  3. await 拿到完整结果,res.content 就是诗歌全文(字符串)。

适用场景

  • 简短问答、翻译、摘要
  • 需要把结果当变量继续做逻辑处理(比如解析 JSON、喂给下一步)
  • 不关心"边生成边显示"的交互体验

缺点

  • 用户要干等整段生成完成。如果模型要写 500 字,可能要等好几秒才"啪"一下全部出现,体验差。
  • 长时间无输出,容易让人以为程序卡死了。

三、stream:流式调用

stream 让模型边生成边返回,前端/终端可以像打字机一样逐字吐出。这才是 ChatGPT 那种"逐字蹦字"体验的底层原理。

ts 复制代码
import { HumanMessage } from "@langchain/core/messages";

const stream = async () => {
  const stream = await llm.stream([new HumanMessage("写一首关于AI的诗")]);
  for await (const chunk of stream) {
    const content = Array.isArray(chunk.content)
      ? chunk.content.map((item) => ("text" in item ? item.text : "")).join("")
      : chunk.content;
    if (content) process.stdout.write(content);
  }
};

stream();

发生了什么

  1. 请求发出,服务端以 SSE(Server-Sent Events) 流的形式,每生成一小块 token 就推一段。
  2. llm.stream(...) 返回一个异步可迭代对象(AsyncIterable)
  3. for await...of 逐块消费,chunk.content 就是这一小段文本。
  4. process.stdout.write(content) 把碎片直接写到终端,不换行、不缓冲

为什么要用 HumanMessage 而不是字符串

stream 接收的是 消息数组 ,不是裸字符串(裸字符串只有 invoke 支持)。标准写法是包一层 HumanMessage

ts 复制代码
import { HumanMessage } from "@langchain/core/messages";

llm.stream([new HumanMessage("写一首关于AI的诗")]);

如果漏掉 import { HumanMessage },TS 会直接报"找不到名称 HumanMessage"。

chunk.content 为什么要做数组判断

LangChain 的 content 类型其实是 string | Array<...>------当返回纯文本时是字符串;当涉及多模态(图片、工具调用等)时会变成内容块数组。所以用 Array.isArray 兜底,保证只取文本部分:

ts 复制代码
const content = Array.isArray(chunk.content)
  ? chunk.content.map((item) => ("text" in item ? item.text : "")).join("")
  : chunk.content;

process.stdout.write vs console.log

  • console.log(content) 每次都会自动加换行 \n,一首诗会被切成一堆带空行的小段,很难看。
  • process.stdout.write(content) 原样输出、不换行,拼起来才是完整连贯的文本。

适用场景

  • 聊天对话、长文生成(体验最佳)
  • 任何需要"即时反馈"的交互
  • 前端打字机效果、Token 用量实时统计

四、两种方式对比

维度 invoke(阻塞式) stream(流式)
返回时机 全部生成完才返回 边生成边返回
返回类型 完整 AIMessage AsyncIterable<chunk>
消费方式 await 一次拿结果 for await...of 遍历
入参 字符串或消息数组 消息数组(推荐 HumanMessage
终端体验 干等后整段出现 逐字打印,像打字机
取文本 res.content chunk.content(需遍历拼接)
适合场景 工具调用、JSON 解析、短答 对话、长文、实时展示
首次出字延迟 高(等整段) 低(首个 token 即出)

一句话总结:要拿结果做后续处理用 invoke,要给用户看的用 stream

五、今天踩过的真实坑

  1. res.content 不是 res.concat 新手容易把字符串的 .concat() 方法当成响应属性,写出来 res.concat 得到 undefined。响应正文是 res.content

  2. stream 入参要用消息对象 llm.stream([["human", "..."]]) 这种元组写法在新版 LangChain 已不支持,会抛 Unable to coerce message from array。改成 llm.stream([new HumanMessage("...")])llm.stream("...")

  3. HumanMessage 必须导入 只写 new HumanMessage(...)import,TS 直接报错"找不到名称"。

  4. .env= 不用 : 写成 NANA_API_KEY:sk-xxx 会导致 dotenv 读不到,apiKey 变空字符串,报 Missing credentials

  5. baseURL 别带 /chat/completions 写成 https://api.deepseek.com/v1/chat/completions,SDK 会再拼一次,最终请求变成 .../chat/completions/chat/completions,直接 404。只写到 https://api.deepseek.com/v1

六、运行验证

bash 复制代码
# 编译
pnpm build
# 运行(默认走 stream)
node dist/index.js

终端会逐字打印出 DeepSeek 写的诗,证明流式调用已通。

想对比 invoke,把被注释的那段解开、注释掉 stream 即可:

ts 复制代码
const res = await llm.invoke("写一首关于AI的诗");
console.log(res.content);

七、小结

  • invoke 是"一次性拿结果",适合程序内部消费;stream 是"边生成边拿",适合给用户看。
  • 流式关键是 AsyncIterable + for await...of + process.stdout.write
  • content 可能是数组,生产代码要做类型兜底。
  • 所有配置(key、baseURL)都来自 .env,改配置不用重新编译,重启进程即可。

下一篇可以聊 多轮对话(带 history 的 messages 数组)RAG 向量检索,把"单次问答"升级成"能记住上下文的助手"。


相关代码nana-ima/1.basic/src/index.ts 环境搭建:见《第一个 AI 调用------TypeScript 工程初始化与配置管理》

相关推荐
吴佳浩4 小时前
Skill 为什么不同于 Tool?Agent 技能库的自演进与动态加载机制
人工智能·agent·ai编程
大模型任我行7 小时前
谷歌:“课程学习”融入扩散模型强化学习
人工智能·语言模型·自然语言处理·论文笔记
AIGCmagic社区7 小时前
具身智能专题:机器人也有Scaling Law?智元GE-Act 2.0用3万小时真机数据给出答案
人工智能·aigc·具身智能
Rosanci8 小时前
谷歌浏览器插件开发实战指南:从 Hello World 到上架发布
大数据·人工智能·chrome·程序人生
明月_清风8 小时前
AI 越来越强,程序员真正的价值到底是什么?
人工智能·后端
m0_466525298 小时前
云从科技上线云起ModelHub:AI团队时代的模型算力基础设施
大数据·人工智能·科技
火山引擎开发者社区9 小时前
OpenViking:给 Codex 加上长期记忆
人工智能
荆棘鸟智能9 小时前
城市感知设备怎么统一接入?从多协议网关到设备模型的中间件架构设计
人工智能·算法·边缘计算
火山引擎开发者社区9 小时前
当 AI 内容真假难辨,谁来为真实签名 —— 证书中心 C2PA 内容可信溯源服务正式发布
人工智能
百万蹄蹄向前冲9 小时前
风扇转了一晚上MVP专家团翻车事故
前端·人工智能