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

227 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Agent 为什么能边问边做:从同步 Bash 到异步工具调用
> 30 分钟内部分享。配套原型在 [`async-agent-lab/`](../),一份本地网页加一个约 300 行的小 Agent,接东财 AI 网关的 `DeepSeek-V4.1-Flash-1017`。文中每个数字都来自 [`recordings/`](../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` 仍在等 |
## 原型怎么跑
```bash
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/`](../replays/) 里已经放了六份。
- **命令行驱动。** `node scripts/drive.ts --level 4 --answer idle:保留一版` 可以无人值守地跑一遍并存成录制,方便课前准备。
> 网关对单个令牌有 **50 次/分钟** 的限制。一次运行大约 11 到 18 次请求,同时跑两三个就会碰到 429。原型会退避重试,但现场别并发开太多。
---
## 第 1 级:最小 Agent
只有三样东西:消息历史、一次模型调用、一个同步 `bash`。
```text
空闲 ──收件箱有东西──▶ 一轮(turn)
├─ 一步(step):请求模型 → 依次执行它要的工具 → 结果加进历史
├─ 每步结束:把收件箱里的东西加进历史
└─ 模型不再调用工具 → 一轮结束,回到空闲
```
整个 Loop 在 [agent.ts:71–85](../agent/agent.ts#L71-L85),核心就这几行:
```ts
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](../agent/llm.ts):OpenAI Chat Completions 协议,流式读取,把按 `index` 分片到达的 `tool_calls` 拼回完整调用。网关会同时返回 `reasoning_content`,页面里可以展开看。
**要强调的一点:** [`runForeground`](../agent/tools.ts#L118-L139) 用的是 `await`,Node 事件循环没被卡住。网页照样能收消息,本地服务照样在推事件。但 Loop 停在 `await this.runTool(...)`,下一次 `callModel()` 发不出去。
**实测([level1-sync](../recordings/level1-sync.json)):**
| 时刻 | 发生了什么 |
| --- | --- |
| 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`](../agent/tools.ts#L142-L176) 起进程后**当场返回** `{"job_id":"job_1","status":"running"}`,这一步结束,Loop 马上发下一次请求。
- 进程退出时([tools.ts:157](../agent/tools.ts#L157)),运行时把退出码和最后 25 行日志包成 `<background_job_done>`,**放进收件箱**。
- 下一个步骤边界,[`deliverInbox`](../agent/agent.ts#L135-L145) 把它作为一条 `user` 消息加进历史。如果 Loop 正空闲,收件箱一有东西就唤醒新的一轮。
**实测([level2-background](../recordings/level2-background.json)):**
| | 第 1 级 | 第 2 级 |
| --- | --- | --- |
| 基线 `npm test` 时长 | 15.6s | 15.7s |
| 测试期间发出的模型请求 | 0 | **3 次**(#2 #3 #4,查调用方、读测试) |
| 插话排队 | 12.6s | **1.8s**(等的是当时那次模型请求结束) |
| 总时长 | 99.2s | 60.1s |
测试本身一秒没快。省下的是测试期间 Agent 本来要空等的那段时间。如果每一步都依赖上一步的结果,后台化就省不出什么。
**系统提示词里要讲清楚的三件事**(见 [levels.ts](../agent/levels.ts)):后台结果会以什么格式到达;在它到达前不许声称测试通过;没事可做时就结束这一轮,不要用 `sleep` 轮询。早期一版提示词没写最后一条,模型在等待时自己调了 `sleep 20`,把后台任务又变回了阻塞。
## 第 3 级:把等待对象换成人(阻塞提问)
加一个 `question` 工具。[`askBlocking`](../agent/tools.ts#L179-L195) 返回一个 Promise,用户点了按钮才 resolve。答案就是工具结果,以 `role: "tool"` 进历史。
**实测([level3-blocking](../recordings/level3-blocking.json)):**
- 2.6s 发出问题。17.6s 用户回答,工具这才返回,**挂了 15.0s**。
- 这 15 秒里,**模型请求数为 0**。
- 6.1s 的插话一直排队到 17.6s,排了 11.5s。
这个级别里 `bash` 已经能后台跑了,问题是提问本身占住了这一步。**等子进程和等人,对 Loop 来说是同一种阻塞。**
## 第 4 级:异步提问(Codex 式)
同一个 `question` 工具换一种实现,[`askAsync`](../agent/tools.ts#L198-L210):
1. 工具**当场返回** `{"accepted":true,"question_id":"q_1"}`。问题卡照常显示。
2. 用户回答时,[`answer`](../agent/tools.ts#L212-L226) 把 `<question_reply question_id="q_1">保留一版</question_reply>` 放进收件箱。
3. 下一个步骤边界,它作为 `user` 消息进历史。Loop 空闲的话就唤醒。
**晚答([level4-async-late](../recordings/level4-async-late.json)):**
- 1.8s 发问题,工具 1.8s 就返回。
- 16.3s 回答前,模型已经发了 **5 次请求**(#2–#6):启动基线测试、读文档、读三个调用方和测试、汇总改动点。然后停下说「已盘完全部调用方……在等你回答 q_1」,结束这一轮。**它没有替用户假设答案。**
- 回答后 #7 立刻看到答案,23.1s 第一次改文件。第 3 级第一次改文件是在 35.8s。
**早答([level4-async-early](../recordings/level4-async-early.json)):**
- 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](../agent/inbox.ts)),所以它们进入模型的时机规则也一样。
## 一个现场发现的坑:模型会冒充系统通知
[level4-forged-notice](../recordings/level4-forged-notice.json) 是一次真实运行,没有剪辑。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](../agent/levels.ts))。
2. 运行时检查模型输出,发现这类标签就记一条 `guard.forged` 事件,并往收件箱放一条提醒,让它作废这个结论([agent.ts](../agent/agent.ts#L100-L108))。页面会在那条回复下标黄。
更稳的做法是不依赖模型自觉。下结论时以运行时的任务状态为准,比如「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](https://github.com/openai/codex/blob/rust-v0.167.1/codex-rs/core/src/tools/handlers/request_user_input_async.rs#L131-L147))。上游场景测试的请求快照里([snap:38–50](https://github.com/openai/codex/blob/rust-v0.167.1/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__astra_async_question_and_answer.snap#L38-L50)):
```text
-- 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-tool-calling-survey.md) 和 [research/async-agent-loop-timeline.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](/Users/appgroup/Desktop/EMdev/dongdong-cowork/src/main/message-queue/__tests__/steer-runtime.test.ts#L501)(`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](../agent/llm.ts) | 模型请求封装:流式 SSE、拼工具调用、429/5xx 退避重试 |
| [agent/config.ts](../agent/config.ts) | 东财网关配置,密钥从 `EASTMONEY_API_KEY` 或 `.env.local` 读 |
| [agent/agent.ts](../agent/agent.ts) | Loop:轮、步、收件箱投递、伪造通知检查 |
| [agent/inbox.ts](../agent/inbox.ts) | 收件箱:三种外部事件的唯一入口 |
| [agent/tools.ts](../agent/tools.ts) | `bash`(前台/后台)、`edit_file`、`question`(阻塞/异步) |
| [agent/levels.ts](../agent/levels.ts) | 四个级别的工具组合和系统提示词 |
| [agent/run.ts](../agent/run.ts) | 一次运行:准备工作区、记录事件 |
| [server/main.ts](../server/main.ts) | 本地服务:开始/插话/回答/停止、SSE 事件流、保存录制、导出回放 |
| [web/src/](../web/src/) | React + Ant Design 的 SPA,构建成单个 HTML |
| [fixture/order-service/](../fixture/order-service/) | 订单服务样例仓库,`npm test` 约 15 秒 |
| [scripts/](../scripts/) | `drive.ts` 命令行驱动,`show.ts` 打印时间线,`stats.ts` 算本文数字 |
**模型行为会变。** 同一个级别多跑几次,请求次数和措辞会不同,偶尔模型会不听话(比如早期版本里用 `sleep` 等待)。现场演示前先跑一遍。如果网关不稳定,就用「打开录制」播放 `recordings/` 里的真实运行,本文数字都取自这些录制。