first commit

This commit is contained in:
魏天炜
2026-11-12 15:29:12 +08:00
commit 5dabea54ab
93 changed files with 12170 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
# Agent 为什么能边问边做
约 30 分钟的内部分享。主讲稿是本地 HTML 网页,由讲者投屏并手动翻页。四张演示页各有可运行的核心实现、任务指令、工具表和实时轨迹。讲者可以在任一页启动、操作和重跑。左侧展示这次运行走的源码,右侧展示轨迹与操作。当前订单原型的运行说明和实测结果见[原型讲稿](share.md)。
## 讲述顺序
| 时间 | 页面 | 投屏内容 |
| --- | --- | --- |
| 0–3 分钟 | Codex 为什么能边问边做 | 从问题卡还开着、Agent 已经继续查代码的现象引入,简短对照 AiWork 的阻塞提问和 OpenCode 的后台 Shell。 |
| 3–6 分钟 | 先给 Agent 四个基础工具 | 用 `read` 展示工具的名字、参数、执行函数和结果。带过 `grep`、`write`、`bash` 各自在周报里做什么。 |
| 6–10 分钟 | 最小 Agent 与同步 Bash | 展示完整的小 Loop,现场查单仓库的提交,看到 Bash 的结果进入下一次请求。 |
| 10–15 分钟 | 后台 Bash | 现场后台收集跨仓库证据,跟一个 `job_id` 看回执、进程退出和模型请求。随后对照 OpenCode V2 的源码和 #63024。 |
| 15–19 分钟 | 阻塞提问 | 用户提交答案前停在工具调用。答完之后,看下一次模型请求带上原工具结果。对照 AiWork 所用的 OpenCode 实现。 |
| 19–26 分钟 | 异步提问 | 问题还挂着,Agent 已经在取证。答完两道题,打开真实请求和最终周报。对照 Codex 的回执和答复消息。 |
| 26–30 分钟 | 后台工作如何回到 Loop | 用一张通信图连接本地进程、子 Agent 和远端任务,回到 AiWork 的「立即插入」与阻塞提问。 |
## 基础工具页
这一页先讲清楚工具契约。模型收到工具定义并返回工具名和参数,运行时查找执行函数。执行结果用原调用 ID 关联,再交回模型。投屏上用一份简短的 `read` 定义和一次调用展示这条链路。下一页再讲 Loop 怎样把它重复起来。
| 工具 | 最小输入 | 周报场景中的用途 |
| --- | --- | --- |
| `grep` | 搜索词、目录 | 在已定位的仓库中找相关文件、符号或测试。返回路径和匹配行。 |
| `read` | 路径,可选行范围 | 读取相关源码、文档和取证摘要。 |
| `write` | 路径、内容 | 在本次演示的工作目录里写 `weekly-report.md` 等产物。 |
| `bash` | 命令 | 执行实际的 `git log`、`git show` 或本地取证命令,返回退出状态和输出。 |
四个工具提供给各页的 Agent。后台 Bash 页给 `bash` 增加后台执行能力,两张提问页各自注册对应的 `question`。每个场景的任务决定会用到哪些工具。最小 Agent 一页可以只调用一两项,把注意力留给工具结果如何进入下一次请求。
## 四个独立的实跑场景
每张演示页有自己的核心入口、系统提示词和任务。共用网关调用、基础工具执行和轨迹采集。每次启动都有干净的周报工作目录和独立的轨迹。
### 最小 Agent 与同步 Bash
> 查 AiWork 本周的 Git 提交,归纳几项研发进展。
左侧展示消息历史、`callModel()`、工具分发和把结果追加进历史的完整小循环。右侧可以依次找到模型的 `tool_calls`、Bash 的退出结果,以及下一次请求中与 `tool_call_id` 匹配的 `tool` 消息。模型最后给出一份简短总结。
### 后台 Bash
> 在后台从本机 Agent 会话定位本周活跃的仓库,再核对 Git 提交。等待期间先列出周报结构。收到结果后,汇报找到了哪些仓库和工作线索。
后台子进程实际运行本周证据收集命令。工具先返回 `job_id`,子进程退出后,运行时把退出码和取证摘要放进会话收件箱。Loop 忙时在下一步接收,空闲时由收件箱唤醒。投屏只跟一个 `job_id`,逐条查看**启动回执、实际退出和结果进入模型请求**。取证命令很快完成时,依然可以用这三个时点讲清回传。AiWork 的回归测试用作同机制的口头例子。
### 阻塞提问
> 整理本周研发周报。先问我下周优先推进什么,再开始查本周工作。
工具在 `await answer` 处等待。讲者留出一段答题时间,右侧显示问题待答和请求计数。提交后,答案成为原工具调用的结果,随后才发出取证所需的模型请求。
### 异步提问
> 整理本周研发周报。先问我下周优先推进什么,等待期间继续收集本周工作。归纳出候选工作项后,让我选择保留哪些,最后写成周报。
工具先返回 `accepted` 和 `question_id`。讲者可以晚一点答,让观众看到等待期间实际发出的取证请求。回答带问题 ID 进入会话,右侧可追到携带它的实际模型请求。候选收齐后显示可勾选的工作项,默认全选,由讲者剔除。最终用 `write` 生成 `weekly-report.md`,检查「本周工作内容」和「下周工作计划」分别使用了哪次回答。
## 周报取证
1. 按上海时区的本周时间窗口,从 Codex、DSH 和 OpenCode 的本地会话找活动时间和工作目录。本周继续使用的旧会话也可提供线索。
2. 用 Git 解析目录对应的仓库,归并共享历史的 worktree。按本周和已确认的 Git 作者查看提交,需要时用 `git show` 核对实际改动。
3. 按工作成果合并候选。已有可核对产出的调研、设计和原型也可以入选。需要补充上下文时,从相关会话或产物读取必要片段。
4. 页面显示实际发现的仓库和候选项。由讲者确认取舍后写入周报。
证据采集用一个本地命令完成会话格式解析,Agent 通过 Bash 调用,并继续用 Git、`grep` 和 `read` 核对具体工作。会话线索用于定位,成果描述以提交、产物或实际验证结果为依据。取证只读,生成文件写入本次演示的工作目录。
## 运行轨迹
右侧参考截图中的事件轨、事件列表和详情结构。默认优先显示当前页讲解所需的关键事件,用「发送方 → 接收方、收到什么、时间」呈现。点击事件可查看对应请求体、模型返回或工具输入输出。问题卡和操作按钮也在当前页。
| 记录对象 | 采集位置和关联方式 |
| --- | --- |
| 模型请求与返回 | 在真正发送 HTTP 请求的位置采集实际序列化的请求体,按请求编号和发送尝试关联 HTTP 状态与返回内容。凭据留在传输层。 |
| 工具执行和后台进程 | 按 `call_id` 关联工具参数与结果,按 `job_id` 关联启动回执、进程退出和完成通知。 |
| 收件箱投递 | 记录答复或完成通知何时入箱、何时加入历史,以及哪一次真实模型请求携带了它。 |
| 网页答复 | 按 `question_id` 关联发问、用户提交、工具返回或答复入箱。 |
轨迹由各页的实际运行产生,按事件顺序排列。同一毫秒发生的回执和投递仍可逐条查看。共用的渲染器消费这些事件,会话正文和问答交互可沿用现有 React 组件。
## 对照产品源码
在对应演示后放短源码片段,围绕刚看到的轨迹定位实现。
- **OpenCode V2 的后台 Bash**展示如何先返回后台回执,再通过 `sessions.synthetic` 交付退出结果。#63024 在开启实验开关时,把超过前台等待预算的同一进程转到后台,复用完成通知。 [后台分支](https://github.com/anomalyco/opencode/blob/v2.2.7/packages/core/src/tool/plugin/shell.ts#L234-L247) · [完成通知](https://github.com/anomalyco/opencode/blob/v2.2.7/packages/core/src/tool/plugin/shell.ts#L150-L173) · [#63024](https://github.com/anomalyco/opencode/pull/63024)
- **AiWork 所用的 OpenCode 1.18.18**展示 `question.ask` 等待,以及 `question.reply` 解除等待。 [问答实现](https://github.com/anomalyco/opencode/blob/v1.18.18/packages/opencode/src/question/index.ts#L104-L131)
- **Codex CLI 0.167.1**展示 `request_user_input_async` 先返回 `accepted`,以及之后的模型请求怎样携带带有问题 ID 的用户答复。 [工具源码](https://github.com/openai/codex/blob/rust-v0.167.1/codex-rs/core/src/tools/handlers/request_user_input_async.rs#L131-L147) · [请求快照](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)
结尾用同一张通信图说明,子 Agent 可以结束自己的 Loop 后向父会话报告结果,远端任务可以通过任务 ID 轮询或回调交付。 [场景调研](../../research/async-tool-calling-survey.md)
## 现场验收
- 主讲稿可在浏览器投屏、手动翻页。演示页上的代码和关键轨迹能同屏读清,可在原地启动和重跑。
- `grep`、`read`、`write` 和 `bash` 都能执行真实操作。同步场景可沿工具调用 ID 追到下一次模型请求。
- 后台 Bash 可沿任务 ID 从回执追到进程退出,再追到携带完成通知的模型请求。
- 两种提问用独立实跑比较。阻塞版答题前停在工具调用。异步版答题前能继续发起取证请求,提交后可找到携带答复的实际请求。
- 最后一段从多个仓库归纳候选,实际写出周报。保留项和下周优先事项与讲者的回答一致。

View File

@@ -0,0 +1,226 @@
# 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/` 里的真实运行,本文数字都取自这些录制。