做 Agent 会用到的 Node API(3):异步与流

本系列 讲实现 Agent harness 时会反复碰到的 Node / JS 运行时能力。默认读者:会一点 JS,但还没系统用过异步与「流式」写法

上一篇:(2)子进程

示例仓库:react-agent-mini

相关前作:150 行搞懂 Agent 主循环


场景:为什么 Agent 离不开「等」和「一段段出来」

做 Agent 时,程序经常在干两件和「时间」有关的事:

  1. 等外部结果:读文件、跑命令、调大模型 API------都不是立刻返回的。
  2. 边等边吐:模型回复往往是流式的,字一个个(或一小段一小段)过来;CLI 要立刻打到屏幕上,而不是等整段说完才显示。

如果用「普通同步函数」硬等:

ts 复制代码
const reply = callModelAndWaitForever(...); // 假想:卡住直到全部说完
console.log(reply);

用户会感觉界面假死;中间也无法取消;工具跑很久时整条进程都堵着。

所以主循环不是「一个函数算完返回字符串」,而是:

text 复制代码
一边跑,一边往外 yield 事件(text_delta、消息......)
外层用 for await 一段段接住

本篇把这条链从零讲清楚:async / await → 异步生成器 → for await → 对照 callModel / query

说明:async / 生成器首先是 JavaScript 语言能力;在 Node 里写 Agent 时几乎天天用。本系列仍按「做 Agent 会踩到的运行时基础」来写。


1. 同步 vs 异步:一句话直觉

同步 异步
调用时 立刻做完,或卡住直到做完 先登记任务,稍后再拿结果
期间进程 很难顺便干别的 可以继续跑事件循环(收别的 IO、定时器)
典型写法 readFileSync、死循环等 await readFileawait fetch......

上一篇的 spawn 也是异步味道:先起子进程,再用事件/Promise 等它结束,而不是函数直接返回命令输出。


2. Promise:一张「稍后兑现」的欠条

ts 复制代码
const p = readFile("a.txt", "utf-8"); // 立刻返回的是 Promise,不是文件内容
const text = await p;                 // 等到读完,text 才是字符串

可以记:

  • Promise = 「这件事还在进行 / 最终会成功或失败」
  • await = 「停在这条 async 函数里,等这张欠条兑现;兑现前把控制权交回事件循环」

只有 async function(以及后面的 async function*)里才能用 await

最小例子:

ts 复制代码
async function load() {
  const text = await readFile("a.txt", "utf-8");
  return text;
}

// 调用方:
const text = await load();

Agent 的工具 call、读盘、等子进程,几乎都是这种「async + await」形状。


3. 普通 async function 不够:还要「多次往外送」

async function 只能 return 一次(一个最终值)。

但 Agent 主循环需要:

  • 先送出一小段 text_delta(供打字机效果)
  • 再送出完整的 assistant 消息
  • 工具跑完再送出 tool_result
  • ......多轮,很多次

这就是 异步生成器async function* + yield

3.1 普通生成器(同步版,先建立直觉)

ts 复制代码
function* count() {
  yield 1;
  yield 2;
  yield 3;
}

for (const n of count()) {
  console.log(n); // 1,然后 2,然后 3
}
  • function*:生成器函数
  • yield:暂停,并把一个值交给外层
  • 外层用 for...of 一次次拿走

3.2 异步生成器:每次 yield 之前可以 await

ts 复制代码
async function* ticks() {
  yield "a";
  await delay(100); // 假想的等待
  yield "b";
}

for await (const x of ticks()) {
  console.log(x);
}

注意外层变成了 for await (... of ...):因为下一项可能还要等 IO。

可以对照记:

写法 往外给几个值 中途能否 await
async function 最多 1 个(return)
function* 多个(yield) 不能(同步)
async function* 多个(yield)

Agent 的 querycallModelrunTools 用的就是第三种。


4. 消费方:for await 在干什么

ts 复制代码
for await (const item of query({ messages, tools, toolUseContext })) {
  if (item.type === "text_delta") {
    process.stdout.write(item.text); // 立刻打到终端
  }
  // 其它类型:完整消息等
}

循环每转一圈:

  1. 向生成器要「下一个值」
  2. 若生成器卡在某个 await(例如还在等模型 chunk),就继续等
  3. 一旦 yield 出来,进入循环体处理
  4. 生成器结束(return)后,for await 退出

示例仓库入口注释里写的也是这套用法:

58:64:src/query.ts 复制代码
 * @example
 * ```ts
 * for await (const item of query({ messages, tools, toolUseContext })) {
 *   if (item.type === 'text_delta') process.stdout.write(item.text)
 * }
 * const { value: terminal } = await gen.next() // 需手动 next 获取 return
 * ```

(若用 for await 只遍历 yield 出的值,生成器的 return ------例如终止原因 Terminal------要另用 gen.next()done 时取。测试里常见「drain」辅助函数就是干这个。)


5. 流式调模型:从 API chunk 到 text_delta

生产路径大致是:

text 复制代码
OpenAI 兼容接口(stream: true)
  → 一串 ChatCompletionChunk(异步可迭代)
  → parseOpenAIStream:拆成 text_delta / 最终 assistant
  → callModel:再 yield 出去
  → query:继续 yield 给 REPL / UI

5.1 callModel:自己也是异步生成器

74:100:src/services/api/client.ts 复制代码
export async function* callModel(
  params: CallModelParams,
): AsyncGenerator<StreamEvent | AssistantMessage> {
  // ...
  const stream = await client.chat.completions.create(
    {
      model: config.model,
      messages,
      tools: tools.length > 0 ? tools : undefined,
      stream: true,
    },
    { signal: params.signal },
  )

  yield* parseOpenAIStream(stream)
}

这里的 yield* 很重要:

  • yield x:自己产出一个值
  • yield* otherGenerator:把另一个生成器产出的值原样转发出去(管道对接)

于是 callModel 不必手写一遍「解析 chunk」的循环,解析逻辑集中在 parseOpenAIStream

5.2 parseOpenAIStreamfor await 读网流,yield 成内部事件

25:39:src/services/api/openai/stream.ts 复制代码
export async function* parseOpenAIStream(
  stream: AsyncIterable<ChatCompletionChunk>,
): AsyncGenerator<StreamEvent | AssistantMessage> {
  let text = ''
  const toolCalls = new Map<number, ToolCallAccumulator>()

  for await (const chunk of stream) {
    const choice = chunk.choices[0]
    if (!choice) continue

    const delta = choice.delta

    if (delta.content) {
      text += delta.content
      yield { type: 'text_delta', text: delta.content }
    }

直觉:

  • 网络上每次来一小片 delta.content
  • 立刻 yield { type: 'text_delta', text: ... },上层就能打印
  • 同时在本地 text += ... 攒全文
  • 流结束(或出现 tool_calls)时,再 yield 一条完整的 assistant 消息,供主循环判断有没有 tool_use

「流」在这里不是 Node 的 fs.createReadStream 那种 Stream 类(那是另一套 API),而是更宽的意思:异步可迭代(AsyncIterable)------用 for await 一段段拿。HTTP 流式响应、异步生成器,都落在这个心智里。


6. 主循环 query:套娃式的 for await + yield

query 本身是 async function*。每一轮里它会:

  1. for await 消费 callModel,把 text_delta / assistant 再 yield 给外层
  2. 若有工具,再 for await 消费 runTools,把 tool_result 消息 yield 出去
  3. 追加历史,continue 下一轮;或 return 终止原因

核心片段(调模型):

150:168:src/query.ts 复制代码
      for await (const chunk of deps.callModel({
        messages: outbound,
        tools: params.tools,
        systemPrompt: params.systemPrompt,
        signal: abortSignal,
      })) {
        if (abortSignal?.aborted) {
          trace('query.turn_end', { reason: 'aborted', turn: turnCount })
          return { reason: 'aborted' }
        }

        if (chunk.type === 'text_delta') {
          yield chunk satisfies StreamEvent
          continue
        }

        if (chunk.type === 'assistant') {
          assistantMessages.push(chunk)
          yield chunk

工具阶段同理:

253:261:src/query.ts 复制代码
    for await (const update of runTools(
      toolUseBlocks,
      parentMessage,
      params.toolUseContext,
    )) {
      if (update.message) {
        yield update.message
        toolResults.push(update.message)
      }
    }

画成管道:

text 复制代码
parseOpenAIStream  yield text_delta / assistant
        ↑ yield*
   callModel
        ↑ for await ... yield
     query
        ↑ for await
   REPL / UI(打印、渲染)

主循环的「转起来」,在代码形态上就是:异步生成器层层对接,而不是一个巨大的回调金字塔。

入口也可以写成 return yield* queryLoop(...):把内部循环生成器的产出与最终 return 一并交给外层------又是 yield* 管道。


7. 另一种消费法:把生成器「抽干」(drain)

有时不需要 把子过程的每个 text_delta 都转给用户,只想:

  • 跑完整个 query
  • 拿到最终 Terminal
  • 顺便收集几条 assistant 做摘要

子代理工具里就是这种模式:手动 gen.next() 循环,直到 done

33:49:src/tools/AgentTool.ts 复制代码
async function drainNestedQuery(
  params: Parameters<typeof query>[0],
): Promise<{
  terminal: Terminal
  assistants: AssistantMessage[]
}> {
  const assistants: AssistantMessage[] = []
  const gen = query(params)
  while (true) {
    const { value, done } = await gen.next()
    if (done) {
      return { terminal: value, assistants }
    }
    if (value.type === 'assistant') {
      assistants.push(value)
    }
  }
}

对比:

方式 适合
for await (const x of gen) 关心每一次 yield(打字、更新 UI)
手动 next 抽干 嵌套跑完要结果;或只要部分事件 + 最终 return 值

同一套 query 生成器,外层怎么消费决定了产品形态:REPL 流式展示,子代理则同步等摘要。


8. REPL 侧:用户输入也可以是异步迭代

会话循环对「一行行用户输入」同样用 for await

147:147:src/entrypoints/repl.ts 复制代码
  for await (const line of deps.lines) {

lines 可以是把 readline 包成的异步生成器。这样「等用户打字」和「等模型吐字」是同一种消费模型,测试时也能塞进假的异步 iterable,不必真连终端。


9. 和「Node Stream 类」的关系(避免名词混淆)

Node 还有 Readable / WritableStream 类 (例如 fs.createReadStream、HTTP IncomingMessage)。它们也能变成异步可迭代,在较新的 Node 里常可以直接:

ts 复制代码
for await (const chunk of readable) { ... }

本篇 Agent 主路径里,你更常直接写的是:

  • async function* + yield / yield*
  • for await 消费

不必先精通整个 Stream 管道(pipebackpressure)才能读懂 query。等真要处理大文件字节流时,再单独补 Stream 类即可。


常见坑

说明 建议
写成普通 async function 却想多次推送 只能 return 一次 需要多次推送就用 async function*
for...of 去套异步生成器 拿不到异步下一项 for await...of
忘记消费生成器 生成器不跑(惰性) 必须 for await 或反复 next()
callModel() 的返回值当「最终字符串」 返回的是生成器对象 要迭代,或抽干后再用结果
yield 最终全文,不 yield delta CLI/UI 无法流式显示 有增量就尽早 yield
嵌套子 query 却把所有 delta 盲目外抛 父 UI 可能被刷屏 按产品决定转发还是 drain

和主循环的关系

text 复制代码
用户一句输入
  → query(async function*)
      → callModel(async function*)
          → parseOpenAIStream(for await 网流 + yield)
      → 若有 tool_use → runTools(async function*)
      → 多轮直到结束 return Terminal
  → REPL for await 打印 text_delta / 消息

前作讲的 ReAct「模型 ↔ 工具」循环,落到 JS 里就是:异步生成器管道。学这部分,是在学主循环怎样「转」而不堵死进程。


本系列下一篇预告

(4)取消与 AbortController------用户按 Ctrl+C、工具超时、嵌套子代理中止时,信号怎么往下传、流式请求怎么停。


你可以带走什么?

  1. await 等一次结果;async function* + yield 多次往外送。
  2. for await 是消费异步生成器 / 异步可迭代的标准姿势。
  3. yield* 用来对接生成器管道 (如 callModelparseOpenAIStream)。
  4. 流式体验 = 尽早 yield 增量text_delta),最后再给完整 assistant
  5. 同一生成器可以流式展示,也可以 drain 只要结果------子代理常用后者。

仓库与延伸

欢迎 Star、Issue 和 PR。


本文为「做 Agent 会用到的 Node API」系列第 3 篇;示例基于 react-agent-mini。

相关推荐
程序员黑豆1 小时前
鸿蒙应用开发之生命周期方法完全指南
前端·harmonyos
一点一木1 小时前
第 23 届 ChinaJoy 刚结束,我把 965 个展台做成了 3D 云展馆
前端·人工智能
wordbaby1 小时前
前端请求缓存写了,同一个接口为什么还是打了十几次?
前端·promise
不可能掉发1 小时前
Env Guard:让浏览器一眼分清生产、测试和开发环境
前端·javascript·chrome·测试工具·html·开源软件·个人开发
饼干哥哥1 小时前
Codex 必改的8 个基础配置
前端·人工智能·后端
小七-七牛开发者1 小时前
Agent 小知识|长任务不重来:Agent 状态保存的工程设计
ai·大模型·agent·claude·token·工作流·skill·claudecode·ai coding
扯蛋4381 小时前
langchain1.x 时代的记忆系统 (二)
javascript·llm·agent
ClouGence1 小时前
AI Agent 能写测试、跑流程,为什么回归测试还不能完全交给 AI?
前端·测试
码云之上1 小时前
Context Engineering:让 Agent 在当前步骤看到正确的事实
前端·人工智能·前端工程化