本系列 讲实现 Agent harness 时会反复碰到的 Node / JS 运行时能力。默认读者:会一点 JS,但还没系统用过异步与「流式」写法 。
上一篇:(2)子进程
示例仓库:react-agent-mini
相关前作:150 行搞懂 Agent 主循环
场景:为什么 Agent 离不开「等」和「一段段出来」
做 Agent 时,程序经常在干两件和「时间」有关的事:
- 等外部结果:读文件、跑命令、调大模型 API------都不是立刻返回的。
- 边等边吐:模型回复往往是流式的,字一个个(或一小段一小段)过来;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 readFile、await 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 的 query、callModel、runTools 用的就是第三种。
4. 消费方:for await 在干什么
ts
for await (const item of query({ messages, tools, toolUseContext })) {
if (item.type === "text_delta") {
process.stdout.write(item.text); // 立刻打到终端
}
// 其它类型:完整消息等
}
循环每转一圈:
- 向生成器要「下一个值」
- 若生成器卡在某个
await(例如还在等模型 chunk),就继续等 - 一旦
yield出来,进入循环体处理 - 生成器结束(
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 parseOpenAIStream:for 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*。每一轮里它会:
for await消费callModel,把text_delta/assistant再 yield 给外层- 若有工具,再
for await消费runTools,把tool_result消息 yield 出去 - 追加历史,
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 / Writable 等 Stream 类 (例如 fs.createReadStream、HTTP IncomingMessage)。它们也能变成异步可迭代,在较新的 Node 里常可以直接:
ts
for await (const chunk of readable) { ... }
本篇 Agent 主路径里,你更常直接写的是:
async function*+yield/yield*for await消费
不必先精通整个 Stream 管道(pipe、backpressure)才能读懂 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、工具超时、嵌套子代理中止时,信号怎么往下传、流式请求怎么停。
你可以带走什么?
await等一次结果;async function*+yield多次往外送。for await是消费异步生成器 / 异步可迭代的标准姿势。yield*用来对接生成器管道 (如callModel→parseOpenAIStream)。- 流式体验 = 尽早 yield 增量 (
text_delta),最后再给完整assistant。 - 同一生成器可以流式展示,也可以 drain 只要结果------子代理常用后者。
仓库与延伸
- GitHub :react-agent-mini
- 前作主循环 :150 行搞懂 Agent 主循环
- 源码 :query.ts · client.ts · stream.ts · AgentTool.ts
欢迎 Star、Issue 和 PR。
本文为「做 Agent 会用到的 Node API」系列第 3 篇;示例基于 react-agent-mini。