Files
agent-loop/async-agent-lab/docs/share.md
2026-11-12 15:29:12 +08:00

17 KiB
Raw Blame History

Agent 为什么能边问边做:从同步 Bash 到异步工具调用

30 分钟内部分享。配套原型在 async-agent-lab/,一份本地网页加一个约 300 行的小 Agent,接东财 AI 网关的 DeepSeek-V4.1-Flash-1017。文中每个数字都来自 recordings/ 里的真实运行,可以用 node scripts/stats.ts 复算。

一句话结论

Agent 能不能边问边做,取决于工具调用什么时候返回,跟 async/await 无关。 Loop 只在工具返回之后才发下一次模型请求。只要一个工具占着这一步不放,不管它在等子进程还是在等人,模型都看不到任何新东西。要让 Agent 边问边做,就得把「发起」和「拿到结果」拆成两件事:工具先回执,结果之后作为新消息进入会话。

分享结构

同一个 Agent、同一份订单服务代码、同一个任务,从第 1 级到第 4 级,每级只多一个能力。

时间 段落 现场操作 观众要看到的
0–3 开场:一个现象 播 Codex 异步提问的请求快照 问题卡还挂着,下一次模型请求已经发出去了。为什么?
3–9 第 1 级:同步 Bash 实时跑,npm test 开始后插一句话 测试 15 秒,模型请求停在 #2;插话进了收件箱,排队 12.6 秒
9–14 第 2 级:后台 Bash 同样的任务、同样的插话 测试还在跑,模型已经发出 #2 到 #4;插话 1.8 秒就被看到
14–20 第 3 级:阻塞提问 故意晚点回答 问题挂了 15 秒,这期间一次模型请求也没有
20–25 第 4 级:异步提问 晚答一次,早答一次 回答前已发出 5 次请求,查完调用方后停在需要答案的那一步
25–28 三个时间点 + 一个坑 点开请求记录;打开「伪造通知」回放 工具返回 ≠ 结果到达 ≠ 模型看到;模型会冒充系统通知
28–30 回到 AiWork 对照集成测试 「立即插入」能收消息,原 question 仍在等

原型怎么跑

cd async-agent-lab
cp .env.example .env.local     # 填 EASTMONEY_API_KEY
pnpm install
pnpm start                     # 构建网页并启动 http://127.0.0.1:8787

页面左边是对话和问题卡,右边上方是泳道时间线,下方是详情、请求记录、收件箱、代码改动、源码、对比和系统提示词。顶栏可以切级别、开始、停止、重置、打开录制、保存录制、导出回放。

  • 全部真实执行。 模型请求是对网关的流式 chat/completions 调用,bash 和 edit_file 在每次运行复制出来的订单服务仓库里真的执行(见 .runs/)。没有桩,也没有模拟返回。
  • 逐步查看。 回放或运行结束后,用 ← → 一个事件一个事件地走,或者拖动滑块。时间线和对话都会回到那一刻的状态。
  • 单文件回放。 点「导出回放」得到一个约 1.5MB 的 HTML,双击就能打开,不需要服务和网络。replays/ 里已经放了六份。
  • 命令行驱动。 node scripts/drive.ts --level 4 --answer idle:保留一版 可以无人值守地跑一遍并存成录制,方便课前准备。

网关对单个令牌有 50 次/分钟 的限制。一次运行大约 11 到 18 次请求,同时跑两三个就会碰到 429。原型会退避重试,但现场别并发开太多。


第 1 级:最小 Agent

只有三样东西:消息历史、一次模型调用、一个同步 bash。

空闲 ──收件箱有东西──▶ 一轮(turn)
                       ├─ 一步(step):请求模型 → 依次执行它要的工具 → 结果加进历史
                       ├─ 每步结束:把收件箱里的东西加进历史
                       └─ 模型不再调用工具 → 一轮结束,回到空闲

整个 Loop 在 agent.ts:71–85,核心就这几行:

for (let step = 0; step < MAX_STEPS_PER_TURN; step += 1) {
  const assistant = await this.callModel();
  if (!assistant.tool_calls?.length) break;          // 模型没再要工具,这一轮结束
  for (const call of assistant.tool_calls) {
    const result = await this.runTool(call.id, call.function.name, call.function.arguments);
    this.append({ role: 'tool', tool_call_id: call.id, content: result });
  }
  this.deliverInbox();                               // 只有这里,外面的消息才进得来
}

模型封装在 llm.ts:OpenAI Chat Completions 协议,流式读取,把按 index 分片到达的 tool_calls 拼回完整调用。网关会同时返回 reasoning_content,页面里可以展开看。

要强调的一点: runForeground 用的是 await,Node 事件循环没被卡住。网页照样能收消息,本地服务照样在推事件。但 Loop 停在 await this.runTool(...),下一次 callModel() 发不出去。

实测(level1-sync):

时刻 发生了什么
3.6s 模型请求 #2 要求跑 npm test,工具开始
6.6s 用户插话「status 在哪几个文件里被读取?」,进了收件箱
19.3s npm test 退出,工具返回(阻塞 15.6s)。同一时刻插话被加进历史
19.3s 模型请求 #3 发出,第一次看到插话。插话排队 12.6s

全程三次 npm test,Loop 在工具上一共停了 47.3s,占总时长 99.2s 的近一半。

第 2 级:同一个 Bash 加后台模式

只改一个工具,Loop 不动。bash 多一个参数 run_in_background:

  • startBackground 起进程后当场返回 {"job_id":"job_1","status":"running"},这一步结束,Loop 马上发下一次请求。
  • 进程退出时(tools.ts:157),运行时把退出码和最后 25 行日志包成 <background_job_done>,放进收件箱。
  • 下一个步骤边界,deliverInbox 把它作为一条 user 消息加进历史。如果 Loop 正空闲,收件箱一有东西就唤醒新的一轮。

实测(level2-background):

第 1 级 第 2 级
基线 npm test 时长 15.6s 15.7s
测试期间发出的模型请求 0 3 次(#2 #3 #4,查调用方、读测试)
插话排队 12.6s 1.8s(等的是当时那次模型请求结束)
总时长 99.2s 60.1s

测试本身一秒没快。省下的是测试期间 Agent 本来要空等的那段时间。如果每一步都依赖上一步的结果,后台化就省不出什么。

系统提示词里要讲清楚的三件事(见 levels.ts):后台结果会以什么格式到达;在它到达前不许声称测试通过;没事可做时就结束这一轮,不要用 sleep 轮询。早期一版提示词没写最后一条,模型在等待时自己调了 sleep 20,把后台任务又变回了阻塞。

第 3 级:把等待对象换成人(阻塞提问)

加一个 question 工具。askBlocking 返回一个 Promise,用户点了按钮才 resolve。答案就是工具结果,以 role: "tool" 进历史。

实测(level3-blocking):

  • 2.6s 发出问题。17.6s 用户回答,工具这才返回,挂了 15.0s。
  • 这 15 秒里,模型请求数为 0。
  • 6.1s 的插话一直排队到 17.6s,排了 11.5s。

这个级别里 bash 已经能后台跑了,问题是提问本身占住了这一步。等子进程和等人,对 Loop 来说是同一种阻塞。

第 4 级:异步提问(Codex 式)

同一个 question 工具换一种实现,askAsync:

  1. 工具当场返回 {"accepted":true,"question_id":"q_1"}。问题卡照常显示。
  2. 用户回答时,answer 把 <question_reply question_id="q_1">保留一版</question_reply> 放进收件箱。
  3. 下一个步骤边界,它作为 user 消息进历史。Loop 空闲的话就唤醒。

晚答(level4-async-late):

  • 1.8s 发问题,工具 1.8s 就返回。
  • 16.3s 回答前,模型已经发了 5 次请求(#2–#6):启动基线测试、读文档、读三个调用方和测试、汇总改动点。然后停下说「已盘完全部调用方……在等你回答 q_1」,结束这一轮。它没有替用户假设答案。
  • 回答后 #7 立刻看到答案,23.1s 第一次改文件。第 3 级第一次改文件是在 35.8s。

早答(level4-async-early):

  • 2.8s 发问题,5.8s 回答「直接移除」。答案在收件箱里等了 1.6s,直到 #3 结束,然后被 #4 消费。
  • 最终 diff 删掉了旧字段,并在文档里写明这是不兼容变更。晚答那次选的是「保留一版」,diff 里留了带 @deprecated 的 status。同一份代码,答案不同,改法就不同,证明答案真的被用上了。

三个时间点

点时间线上任何一个后台任务或问题,会画出三条竖线。这是全场最想让观众带走的东西:

后台 npm test(第 2 级 job_1) 阻塞提问(第 3 级) 异步提问(第 4 级晚答)
① 工具返回 1.9s 17.6s 1.8s
② 结果到达 17.6s 17.6s 16.3s
③ 模型看到 17.6s(#5) 17.6s(#2) 16.3s(#7)

阻塞时 ① ② ③ 粘在一起。异步就是把 ① 往前拉,让 ① 和 ② 之间能塞进别的工作。② 到 ③ 的间隔取决于结果到达时 Loop 在干什么:空闲就立刻唤醒,正在请求模型就等这次请求结束。

用户插话、后台完成、问题答复,在这个原型里走的是同一个收件箱(inbox.ts),所以它们进入模型的时机规则也一样。

一个现场发现的坑:模型会冒充系统通知

level4-forged-notice 是一次真实运行,没有剪辑。30.8s 模型请求 #11 的回复里,模型自己写出了一段 <background_job_done job_id="job_2" exit_code="0" ...>,编了 12 项全过的测试输出,接着宣布「改完了」。真正的完成通知 41.6s 才到,晚了 10.8 秒。碰巧这次测试确实通过了,所以结论没错,但这个结论在测试还在跑时就给出了。

原因不难理解:通知是用带标签的 user 消息送进来的,模型在前面已经见过一次 job_1 的通知,就照着格式续写。任何把结果塞进消息通道的设计都有这个风险。 Codex 的 <send_user_message_question_reply> 也属于这一类。原型加了两道防线:

  1. 提示词明确说这些标签只由系统发出(levels.ts)。
  2. 运行时检查模型输出,发现这类标签就记一条 guard.forged 事件,并往收件箱放一条提醒,让它作废这个结论(agent.ts)。页面会在那条回复下标黄。

更稳的做法是不依赖模型自觉。下结论时以运行时的任务状态为准,比如「job_2 仍在运行」时不允许把这一轮标成完成。也可以像 Responses API 的原生异步工具那样,把结果绑回原来的 call_id,不走普通用户消息。

对照真实实现

Codex CLI 0.167.1 的 request_user_input_async 是第 4 级的原型。处理函数先把问题作为一条 agent message 发出,然后直接返回 {"accepted":true}(request_user_input_async.rs:131–147)。上游场景测试的请求快照里(snap:38–50):

-- request 2 --
function_call/functions.request_user_input_async:{"questions":[{"options":["Internal team","Customers"], ...}]}
function_call_output:{"accepted":true}
-- request 3 --
message/assistant:  I drafted a short launch update.
message/user:
    <send_user_message_question_reply>
    [{"answer":"Customers", ..., "questionItemId":"[\"request_user_input_async\",\"audience-question\",0]"}]
    </send_user_message_question_reply>
-- request 4 --
message/assistant:  Here is the launch update for customers.

和原型一一对应:function_call_output 是回执;答案是一条带问题 ID 的 user 消息;答案到达前模型已经起好了草稿。区别在于 Codex 的问题 ID 是结构化的条目 ID,原型简化成了 q_1。

其他产品的现状和出处在 research/async-tool-calling-survey.md 和 research/async-agent-loop-timeline.md 里,这里只列开场对照要用的几条:

产品 提问 长命令
Codex CLI request_user_input_async 先回执,答案作为带 ID 的用户消息 exec_command 超时后返回 process ID,退出事件发回原 turn
OpenCode 内置 question 暂停执行,等用户填完表单 V2 有后台 shell,完成通知唤醒原会话
Claude Code SDK 的 AskUserQuestion 走阻塞的 canUseTool 回调 Ctrl+B 转后台;超时自动转后台
OpenAI Responses API 工具可声明 async: true,答案作为稍后的 function_call_output 绑回原 call_id 同一机制

回到 AiWork

AiWork 的 question 走 OpenCode 内置工具,属于第 3 级。集成测试 steer-runtime.test.ts:501(native Question ... keeps the same pending question through insertion,仓库 059262ef2)把这件事写得很清楚:

  1. 模型调用 question,测试等到问题出现在 question.list 里。
  2. 用户「立即插入」一条消息,dispatcher.steer 返回 delivery: "continuation"。
  3. 断言原问题仍在等待列表里,并且模型请求仍然只有 1 次(expect(f.requests).toHaveLength(1))。
  4. 用户回答后,第 2 次请求才发出,里面同时带着插入的消息和答案。

所以「立即插入」接住了消息,但 Agent 没有因此继续干活。这和第 3 级的实测一致:插话排队 11.5s,问题挂着期间请求数为 0。

改成边问边做需要的东西,就是第 3 级到第 4 级的那点差别,再加上这次踩到的坑:

  1. 提问先返回。 工具结果改成回执,问题本身由界面另行展示和保存。
  2. 答案能关联回问题。 答案带问题 ID 进会话。界面要能在同一时刻显示多个未答问题,并标出哪个已被模型消费。
  3. 能续跑。 会话空闲时,答案到达要能唤醒新一轮;会话正忙时,答案在下一个步骤边界进入。这和后台命令完成通知是同一条路径。
  4. 提示词和约束。 告诉模型等待时做什么、走到依赖处怎么停、不要替用户假设答案、不要自己写系统标签。
  5. 结论以运行时状态为准。 有未答问题或在跑的任务时,不能只凭模型说「完成了」就收尾。

前两件可以在 AiWork 侧用自定义工具实现,不必等 OpenCode 上游。第 3 件 AiWork 已有雏形:集成测试里的 continuation 投递,以及后台任务完成后的续跑。

附:原型里每个文件做什么

文件 内容
agent/llm.ts 模型请求封装:流式 SSE、拼工具调用、429/5xx 退避重试
agent/config.ts 东财网关配置,密钥从 EASTMONEY_API_KEY 或 .env.local 读
agent/agent.ts Loop:轮、步、收件箱投递、伪造通知检查
agent/inbox.ts 收件箱:三种外部事件的唯一入口
agent/tools.ts bash(前台/后台)、edit_file、question(阻塞/异步)
agent/levels.ts 四个级别的工具组合和系统提示词
agent/run.ts 一次运行:准备工作区、记录事件
server/main.ts 本地服务:开始/插话/回答/停止、SSE 事件流、保存录制、导出回放
web/src/ React + Ant Design 的 SPA,构建成单个 HTML
fixture/order-service/ 订单服务样例仓库,npm test 约 15 秒
scripts/ drive.ts 命令行驱动,show.ts 打印时间线,stats.ts 算本文数字

模型行为会变。 同一个级别多跑几次,请求次数和措辞会不同,偶尔模型会不听话(比如早期版本里用 sleep 等待)。现场演示前先跑一遍。如果网关不稳定,就用「打开录制」播放 recordings/ 里的真实运行,本文数字都取自这些录制。