From 6011f501aa17d083c3cbb90297597d1e6a6b530f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AD=8F=E5=A4=A9=E7=82=9C?= Date: Thu, 12 Nov 2026 16:24:22 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=88=86=E4=BA=AB=E7=AB=99?= =?UTF-8?q?=EF=BC=9A=E7=BB=9F=E4=B8=80=E8=AF=B4=E6=98=8E=E6=96=87=E5=AD=97?= =?UTF-8?q?=E3=80=81=E5=90=88=E5=B9=B6=E8=A1=8C=E4=B8=9A=E5=AF=B9=E6=AF=94?= =?UTF-8?q?=E9=A1=B5=E3=80=81=E7=A7=BB=E9=99=A4=E6=97=A7=20explainers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 页面从 11 页缩减为 9 页,三个独立产品源码页合并为一张行业对比表 - 四个演示页说明文字重写:每页说清和上一级的区别、机制为什么相同 - 开场从 AiWork vs Codex 具体现象切入,工具页从 fs.readFileSync 引入契约层 - Loop 基本逻辑页说明这是真实可跑的原型,解释 await runTool 卡住的是什么 - 结尾页改为「收件箱统一三条路径」,回扣到 AiWork 的立即插入 - 新增 CompareSlide 组件,对比 AiWork / Codex / Claude Code / OpenAI Responses API - 删除 ProductSlide 配置式组件,改为独立页面 - 关键事件卡片加标题标签,补 mode-tabs 激活态和 compare-table 样式 - 删除 explainers/ 下四个旧文件 - 更新 README.md 和 async-agent-lab/README.md --- README.md | 24 +- async-agent-lab/README.md | 31 +- async-agent-lab/web/src/stage/Demo.tsx | 9 +- async-agent-lab/web/src/stage/Lesson.tsx | 63 ++- async-agent-lab/web/src/stage/Slides.tsx | 136 +++-- async-agent-lab/web/src/stage/content.ts | 14 +- async-agent-lab/web/src/stage/main.tsx | 4 +- async-agent-lab/web/src/stage/stage.css | 33 ++ explainers/agent-loop-and-execution.html | 642 ----------------------- explainers/cloud-agent-loop-film.html | 620 ---------------------- explainers/compute-state-separation.html | 497 ------------------ explainers/run-agent-loop-lab.mjs | 444 ---------------- 12 files changed, 208 insertions(+), 2309 deletions(-) delete mode 100644 explainers/agent-loop-and-execution.html delete mode 100644 explainers/cloud-agent-loop-film.html delete mode 100644 explainers/compute-state-separation.html delete mode 100644 explainers/run-agent-loop-lab.mjs diff --git a/README.md b/README.md index c2a35c0..a5aa8f1 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # Agent Loop -一份关于 Agent 如何调用工具、等待后台任务和接收用户答复的分享与演示项目。主讲稿从最基础的 Agent Loop 讲起,并提供同步 Bash、后台 Bash、阻塞提问和异步提问四个可运行的案例。 +约 30 分钟的内部技术分享:Agent 为什么能边问边做。从一个 15 行的真实 Agent 原型出发,通过四个递进级别说明工具调用的返回时机如何决定 Loop 能做什么。 -使用 Node.js 24 和 pnpm。在 `async-agent-lab` 中复制 `.env.example` 为 `.env.local`,配置 `EASTMONEY_API_KEY`,然后运行: +## 启动 + +需要 Node.js 24 和 pnpm。复制 `async-agent-lab/.env.example` 为 `.env.local`,填入 `EASTMONEY_API_KEY`,然后: ```sh cd async-agent-lab @@ -10,4 +12,20 @@ pnpm install --frozen-lockfile pnpm start ``` -打开 [本地主讲稿](http://127.0.0.1:8791/),在案例页切换源码讲解和现场运行。更多说明见 [Agent Loop 实验室](async-agent-lab/README.md)。 +打开 [http://127.0.0.1:8791/](http://127.0.0.1:8791/),用左右方向键或底部按钮翻页,点「全屏」进入投屏模式。 + +## 分享结构 + +| 页面 | 内容 | +| --- | --- | +| 开场 | 从 AiWork 与 Codex 的具体差异引入,说明问题的根源 | +| Agent Loop 基本逻辑 | 15 行真实原型,解释 `await runTool` 卡住的是什么 | +| 四个基础工具 | 从 `fs.readFileSync` 引入工具契约的作用 | +| 第 1 级:同步 Bash | 建立基线,观察工具等待期间请求数为 0 | +| 第 2 级:后台 Bash | 只加一个参数,工具先还回执,Loop 继续运行 | +| 第 3 级:阻塞提问 | 等的从进程换成人,机制完全一样 | +| 第 4 级:异步提问 | 把「发问题」和「拿答案」拆开,和第 2 级路径相同 | +| 行业对比 | AiWork / Codex / Claude Code / OpenAI Responses API 各自的处理方式 | +| 收件箱统一三条路径 | 后台进程、子 Agent、用户答复走同一个机制,回到 AiWork | + +更多说明见 [async-agent-lab/README.md](async-agent-lab/README.md)。 diff --git a/async-agent-lab/README.md b/async-agent-lab/README.md index 15f3bef..929759c 100644 --- a/async-agent-lab/README.md +++ b/async-agent-lab/README.md @@ -1,28 +1,37 @@ # Agent 为什么能边问边做 -本地 HTML 主讲稿。讲解页先介绍 Agent Loop 和基础工具。四张案例页均可切换「源码与说明」与「现场运行」,运行模式左侧显示对话与问答,右侧以角色标签展示事件轨迹。页面顺序见[分享方案](docs/presentation-plan.md)。 +约 30 分钟内部技术分享的演示站。四个递进级别,每级只改一件事,每次都能在请求时序里看到变化的那个点。 ## 启动 -使用 Node.js 24 和 pnpm。在 `.env.local` 中配置 `EASTMONEY_API_KEY`。本机已有配置时直接启动。 +需要 Node.js 24 和 pnpm。复制 `.env.example` 为 `.env.local`,填入 `EASTMONEY_API_KEY`: ```bash -cd /Users/appgroup/Documents/ChatGPT/super-agent-loop/async-agent-lab pnpm install --frozen-lockfile pnpm start ``` -打开 [本地主讲稿](http://127.0.0.1:8791/)。用左右方向键、底部按钮或目录翻页。内容按窗口宽度排版,源码、候选项和轨迹随页面滚动。点「全屏」进入投屏模式。打开详情后用 Esc 关闭。演示页可独立启动、停止或重跑,切到源码对照页再返回时会保留当前运行和答复草稿。 +打开 [http://127.0.0.1:8791/](http://127.0.0.1:8791/)。用左右方向键或底部按钮翻页,点「全屏」进入投屏模式。 -- **同步 Bash**:沿 `tool_call_id` 从 Git 结果追到下一次请求。 -- **后台 Bash**:沿 `job_1` 看启动回执、进程退出和完成通知。点请求事件可以展开对应的 `user` 消息及实际序列化请求体。 -- **阻塞提问**:让问题卡停几秒,观察请求数。提交后展开下一次请求,核对原 `tool` 消息里的答案。 -- **异步提问**:先等到「继续取证」亮起,再提交下周计划。候选默认全选,取消若干项后提交。点「打开 weekly-report.md」查看磁盘上实际写出的周报。 +## 四个演示级别 + +每个演示页可以切换「源码与说明」和「现场运行」两种模式。运行时左侧显示对话和问答交互,右侧显示事件轨迹。 + +- **第 1 级·同步 Bash**:沿 `tool_call_id` 从 Bash 返回追到下一次请求,观察两次请求之间的空白。 +- **第 2 级·后台 Bash**:沿 `job_1` 看启动回执、进程退出、完成通知进入请求。测试跑着,模型继续工作。 +- **第 3 级·阻塞提问**:让问题卡停几秒,观察请求数不动。提交后核对请求里的 `tool` 消息。 +- **第 4 级·异步提问**:先别答,看取证请求继续出现。答完两道题后打开 `weekly-report.md`,核对两份答案各自进了哪段。 ## 取证与网关 -取证用上海时间的本周工作日范围,截止到运行时或周五结束。读取 Codex 和 OpenCode 的本地会话数据库及 DSH 会话缓存,按 Git common directory 归并 worktree。默认以本机 AiWork 仓库的 `git config user.email` 过滤作者。可通过 `AIWORK_REPO` 指定 AiWork 路径,通过逗号分隔的 `WEEKLY_AUTHOR_EMAILS` 指定已确认的作者邮箱。Bash 支持本场演示用的 `weekly-evidence`、`git log` 和 `git show `。每次启动的独立目录位于 `.runs/`,周报由 `write` 写入该次运行目录。 +取证用上海时间的本周工作日范围。读取 Codex 和 OpenCode 的本地会话数据库及 DSH 会话缓存,按 Git common directory 归并 worktree。默认以本机 AiWork 仓库的 `git config user.email` 过滤作者。可通过 `AIWORK_REPO` 指定路径,通过逗号分隔的 `WEEKLY_AUTHOR_EMAILS` 指定作者邮箱。每次运行的独立目录在 `.runs/`,周报写入该次运行目录。 -服务监听本机回环地址。请求走东财 AI 网关的 OpenAI Chat Completions 流式接口,模型为 `DeepSeek-V4.1-Flash-1017`。模型窗口 1,000,000,最大输出 384,000,支持 text/image。本场使用文本工具调用。 +请求走东财 AI 网关,模型为 `DeepSeek-V4.1-Flash-1017`(OpenAI Chat Completions 流式接口)。网关对单个令牌限速 50 次/分钟,不要并发开多个演示。 -开发时可分别运行 `pnpm server` 与 `pnpm dev`(Vite 5178 代理至 8791)。运行 `pnpm typecheck` 检查类型。此前的订单原型见[原型讲稿](docs/share.md)。 +## 开发 + +```bash +pnpm server # 仅启动后端(端口 8791) +pnpm dev # 仅启动 Vite 开发服务器(端口 5178,代理至 8791) +pnpm typecheck +``` diff --git a/async-agent-lab/web/src/stage/Demo.tsx b/async-agent-lab/web/src/stage/Demo.tsx index 4d9efae..9beed05 100644 --- a/async-agent-lab/web/src/stage/Demo.tsx +++ b/async-agent-lab/web/src/stage/Demo.tsx @@ -199,9 +199,12 @@ export function Demo({ scenario, config, available, active }: { scenario: Scenar
{status}
请求 {request?.n ?? 0} -
- {signalsNow.map(({ label, event }) => )} +
+ 这一级的关键时刻 +
+ {signalsNow.map(({ label, event }) => )} +
{error || current?.state === 'error' ?
{error || current?.note}
: null} {(report || current?.summary) &&
diff --git a/async-agent-lab/web/src/stage/Lesson.tsx b/async-agent-lab/web/src/stage/Lesson.tsx index 7c6b8e5..7a44af3 100644 --- a/async-agent-lab/web/src/stage/Lesson.tsx +++ b/async-agent-lab/web/src/stage/Lesson.tsx @@ -2,40 +2,40 @@ import type { ScenarioId } from '../../../live/types.ts'; const lessons: Record = { sync: { - intro: '先用一份消息历史保存任务。每次请求模型,它可能给出文本,也可能要求执行工具。运行时把工具结果追加到历史,再请求一次模型,这个反复执行的过程就是最小 Agent Loop。', + intro: '这是四个场景的起点和基线。整个 Loop 在 agent.ts 里是 15 行代码:请求模型、依次等工具结果、追加历史、循环。await runTool 是真正的阻塞——不只是这个工具调用卡住了,而是整个 Loop 停在这里等 Promise resolve,收件箱里的消息也进不来,下一次 callModel() 发不出去。先用这个版本建立基准,之后每一步只改一件事。', steps: [ - ['把上下文交给模型', 'callModel(history) 发送当前历史和工具定义。模型返回的 tool_calls 描述工具名称、参数和调用 ID。'], - ['执行工具并等待结果', '运行时按工具名找到 Bash 执行函数。await runTool 在命令退出前保持等待,这时 Loop 还没有发起下一次模型请求。'], - ['追加结果,继续一轮', '用 role: tool 和原 tool_call_id 把结果接回历史。模型在下一次请求中读到 Git 提交,再形成总结。本例在成功取证后要求模型给出文本答复。'], + ['把上下文交给模型', 'callModel(history) 发送当前消息历史和工具定义。模型返回 tool_calls,里面有工具名称、参数和一个 call_id。这个 call_id 是后面对账的钥匙——它把这次调用和之后的结果串起来。'], + ['在 await 处停住', '运行时按工具名找到 Bash 执行函数,把参数传过去。await runTool 在命令退出前让这段 Loop 代码挂在这里。Node.js 事件循环没被卡死,网页仍然响应,但 Loop 的下一步发不出去。'], + ['结果追回历史,继续一轮', '命令退出后,用 role: "tool" 和原来那个 call_id 把结果追加进历史。模型在下一次请求里读到 Git 提交,可以给出汇总。两次请求之间的时间差,就是 Bash 运行的时间。'], ], - observation: '运行时留意请求 #1 与请求 #2 之间的 Bash 返回。工具调用和返回通过同一个 call_id 对应。', + observation: '找到请求 #2 里 tool_calls 中的 call_id,再找请求 #3 里 role 为 tool 的那条消息——同一个 id。两次请求之间的空白就是 Bash 在运行的时间,这段时间里 Loop 什么都没做。', }, background: { - intro: '跨仓库取证由一个本地子进程执行。Bash 工具启动它后先返回 job_id,Loop 便可以请求模型整理周报结构。进程在自己的生命周期里继续工作。', + intro: '和第 1 级代码完全一样,只给 bash 加了一个参数 run_in_background。工具改成先返回 job_id 回执,Loop 拿到回执就结束了这一步,马上发下一次模型请求。子进程在自己的生命周期里继续工作,退出时运行时把结果包成一条消息放进收件箱,步骤边界再交给模型。Loop 代码没变,改变的只是这个工具什么时候返回。', steps: [ - ['回执结束当前工具调用', 'startBackground 返回 accepted 和 job_id。此时模型知道工作已经启动,具体的提交证据仍要等进程完成。'], - ['完成结果进入收件箱', '进程退出时,运行时记录退出码和输出,并投递带 job_id 的通知。这个结果可以在 Loop 忙碌时先排队。'], - ['下一次请求接收结果', 'callModel 发请求前把收件箱加入历史。如果 Loop 已在等待,收件箱中的新消息会唤醒它。'], + ['回执结束当前工具调用', 'startBackground 启动子进程后立刻返回 {job_id, status: "running"}。这就是这次工具调用的全部结果——回执,不是最终输出。Loop 拿到回执,这一步就结束了,可以继续请求模型。'], + ['进程退出,结果进收件箱', '子进程退出时,运行时拿到退出码和最后 25 行输出,包成 消息放进收件箱。用户插话走同一个收件箱,优先级规则也一样。'], + ['步骤边界取走结果', '每次步骤结束,Loop 把收件箱清空追加进历史。如果 Loop 此时已经空闲,收件箱里有东西就唤醒新的一轮,不需要轮询。'], ], - observation: '按 job_1 依次观察启动回执、进程退出、模型请求携带完成通知。取证很快时,三个事件仍有明确的先后顺序。', + observation: '跟一个 job_1:先找工具结果里的启动回执,再找进程退出事件,最后找携带 background_job_done 的模型请求。取证命令很快时,三个时间点依然有先后顺序,不会重叠。', }, blocking: { - intro: '这个场景先询问下周计划。question 工具打开网页问题卡后,等待浏览器提交答案。等待发生在工具调用内部,因此本次调用还没有结果可以交回模型。', + intro: '把等待对象从子进程换成了人,机制完全一样:await answer 挂住了这次 question 工具调用,Loop 停在等工具结果的地方,收件箱里的消息也进不来。这就是 AiWork 今天的 question 工具在做的事——OpenCode 源码里的 Deferred.await。答案没提交,Promise 就不 resolve,模型请求数停在 1。', steps: [ - ['创建等待中的问题', 'openQuestion 返回问题信息和一个 Promise。运行时用问题 ID 保存等待关系,让浏览器答复能找到它。'], - ['等待用户回答', 'await answer 暂停当前工具的执行。网页仍能操作,但这条 Loop 要等工具返回才能继续请求模型。'], - ['答案作为工具结果返回', '浏览器提交后解除 Promise。原 question 调用得到用户答案,再用同一个 tool_call_id 写入历史,随后开始周报取证。'], + ['创建等待中的问题', 'openQuestion 把问题信息发到浏览器,返回一个 Promise 和问题 ID。运行时把 ID 和这个 Promise 保存起来,等浏览器的回答过来时能找到它。'], + ['在 await 处停住,等人', 'await answer 让这次工具调用挂在这里。等的不再是子进程退出,而是用户点提交。等待期间,Loop 和第 1 级的 Bash 等待一模一样——什么都做不了。'], + ['答案作为工具结果返回', '浏览器提交后 Promise resolve,答案就是这次 question 调用的工具结果,用原来的 tool_call_id 追加进历史。这之后 Loop 才继续查证据,和第 1 级 Bash 退出之后继续一模一样。'], ], - observation: '留出几秒答题时间。问题卡待答期间,请求数应停在 1;提交后再观察请求 #2 中的 tool 消息。', + observation: '答题前,右侧请求数保持在 1。故意多等几秒。提交后,找请求 #2 里的 tool 消息——里面带着 question 调用的 call_id 和你输入的答案文本。', }, async: { - intro: '异步 question 打开问题卡后立即返回 accepted 和 question_id。Loop 得到回执,便继续收集本周证据。用户的答案稍后作为一条新的消息进入收件箱。', + intro: '把「发问题」和「拿答案」拆成两件事,走的是和第 2 级后台 Bash 完全相同的路径:工具先返回回执,答案之后作为新消息进收件箱,步骤边界再交给模型。Loop 在等答案的这段时间可以继续取证。对 Loop 来说,问题的答复和子进程的退出通知没有区别,都是收件箱里的一条消息。', steps: [ - ['先问,再继续独立工作', 'ask 返回问题 ID,collect 随即定位活跃仓库并核查 Git。下周优先事项尚未确定,也可以收集本周已经发生的工作。'], - ['把后来的答复接回历史', '浏览器提交的答案带有 question_id。运行时将它加入下一次模型请求,模型可据此识别它回答的是哪一道题。'], - ['在依赖答案的步骤汇合', '候选工作项归纳完成后再请讲者选择。waitForAnswer 等齐两项决策,write 才将选中的本周工作和下周计划写入周报。'], + ['发问,立刻还回执', 'ask 返回 {accepted: true, question_id: "q_1"},这就是这次工具调用的全部结果。问题卡照常显示,但 Loop 已经拿到回执,可以继续下一步取证,不用等你回答。'], + ['答复进收件箱,带问题 ID', '你提交后,服务端把 包成一条 user 消息放进收件箱。模型在下一次请求里看到这条消息,能认出这是哪道题的答案。'], + ['在依赖答案的步骤汇合', '候选工作项归纳完成后,waitForAnswer 等齐两项回答,write 才把本周选中的工作和下周计划写入周报。两份回答的内容各自出现在周报对应的段落里,证明答案真的被用上了。'], ], - observation: '先让问题卡保持待答,观察取证对话继续出现。随后答复计划、确认保留项,并核对写出的 weekly-report.md。', + observation: '先让问题卡保持待答,观察右侧取证对话继续出现、请求数继续增加。再提交下周计划,然后勾选本周保留项。最后打开周报,核对两次回答各自出现在哪一段。', }, }; @@ -51,24 +51,23 @@ export function Lesson({ scenario }: { scenario: ScenarioId }) { export function LoopLesson() { return

最基础的 Agent Loop 是什么?

-

一个围绕消息历史重复执行的程序,让模型根据工具的真实结果决定下一步。

+

这不是伪代码。下面这段 15 行的循环接的是真实的 DeepSeek 网关,跑出来的请求和返回都是真的。OpenCode、Codex、AiWork 用的 Agent 都是同一种结构的变体——区别只在工具怎么返回、Loop 怎么等。

-

模型负责提出下一步,运行时负责执行

-

用户提出任务后,运行时把任务、已有对话和工具定义发给模型。模型可能直接回答,也可能返回一个工具调用,例如让 Bash 查询 Git 提交。

-

收到工具调用后,程序找到对应的执行函数,把参数交给它。执行结果连同调用 ID 一起追加到消息历史,模型才能在下一次请求中使用这些新信息。

-

只要模型继续请求工具,这个过程就继续重复。模型给出最终文本时,这一轮可以结束。如果之后有后台结果或用户答复到达,运行时可以再次启动一轮。

-

“等待”发生在哪里?

-

同步工具会在执行完成后返回,因此 Loop 的下一步要等待它。异步工具先返回启动回执,运行时再通过后续消息交付结果。接下来的四个案例逐步展示这两种时序。

+

模型提出下一步,运行时执行

+

把任务、已有对话和工具定义发给模型。模型可能直接给文本答复,也可能返回一个 tool_calls,里面带着工具名、参数和一个 call_id。运行时按工具名查到对应的执行函数(就是普通的 async 函数),把参数传进去,拿到结果。

+

结果连同原来那个 call_id 一起,以 role: "tool" 的形式追加进消息历史。下一次请求模型时,模型就能读到这份结果,决定下一步。只要模型还想要工具,这个循环就继续;模型给出纯文本时,这一轮就结束。

+

"等待"到底发生在哪里

+

关键就在 await runTool()。这是一个普通的 await,Node.js 事件循环没被卡死——网页、本地服务、收件箱照常工作。但是 Loop 的代码停在这一行,下一个 callModel() 发不出去。同步工具要等命令跑完才返回;异步工具先还回执,Loop 就能继续。后面四个案例,改的都是这一处什么时候 resolve。

{[ - ['用户任务与消息历史', '历史保存模型已经知道的任务、对话和工具结果。'], - ['请求模型', '把消息历史和可调用的工具定义发送给模型。'], - ['执行模型请求的工具', '按工具名分发,将参数传给真实的执行函数。'], - ['结果追加回历史', '保留调用 ID,再请求模型,直到这一轮结束。'], + ['用户任务与消息历史', '历史里保存任务、已发生的对话和已拿到的工具结果。每次请求把它整包发给模型。'], + ['请求模型', '把消息历史和这次能用的工具定义一起发送。模型返回文本或 tool_calls。'], + ['执行模型请求的工具', '按工具名查到执行函数,传参数进去。这里是唯一会卡住的地方——同步工具要等结果,异步工具先还回执。'], + ['结果追加回历史', '保留 call_id,以 role: "tool" 追加进历史,再请求模型。模型不再要工具时这一轮结束。'], ].map(([title, body], i) =>
{i + 1}

{title}

{body}

)} -

工具结果回到第 2 步,形成 Loop。

+

工具结果回到第 2 步,形成 Loop。后台结果和用户答复也一样,都从收件箱进入第 2 步。

; diff --git a/async-agent-lab/web/src/stage/Slides.tsx b/async-agent-lab/web/src/stage/Slides.tsx index 8bbc70c..8083499 100644 --- a/async-agent-lab/web/src/stage/Slides.tsx +++ b/async-agent-lab/web/src/stage/Slides.tsx @@ -1,24 +1,24 @@ import { useState } from 'react'; import { Code } from './Code.tsx'; -import { codexAsync, opencodeQuestion, opencodeShell, readExecution, toolContract } from './content.ts'; -import type { Source } from './content.ts'; +import { readExecution, toolContract } from './content.ts'; export function Intro({ next }: { next: () => void }) { return

Agent 为什么能边问边做

-

问题卡还在等答复,Agent 已经继续查询代码。这次分享从最基础的 Agent Loop 讲起,逐步观察工具何时返回,以及稍后到达的结果怎样被模型接收。

- +

AiWork 里问一个问题,模型就停下来等你回答,这期间什么都不做。Codex 不是这样——问题卡还挂着,取证的请求已经发出去了。这个差别不来自框架有多复杂,只来自工具调用什么时候返回。

+

这次分享从一个 15 行的真实 Agent 原型开始,把机制一步步拆开。四个级别,每级只改一件事,每次都能看到请求时序真正变化的那个点。

+
问题待答 q_1 下周优先推进什么?
-
Agent 正在取证 请求 #2 +
Agent 正在取证 请求 #3 从本地会话定位活跃仓库
-
AiWork 的阻塞提问 · OpenCode 的后台 Shell · Codex 的异步提问
+
问题挂着,取证继续——今天 AiWork 做不到,这次分享讲为什么
; } @@ -26,8 +26,8 @@ export function Intro({ next }: { next: () => void }) { export function ToolsSlide() { const [execution, setExecution] = useState(false); return
-

先给 Agent 四个基础工具

模型说要调用什么,运行时执行,再用调用 ID 交回结果。

-

工具定义告诉模型它能做什么,以及需要提供哪些参数。执行函数负责实际读取文件或启动命令。模型返回一次 read 调用后,运行时会把文件内容作为工具结果送回;调用 ID 让模型知道这份结果属于之前的哪一次请求。

+

先给 Agent 四个基础工具

在 Node.js 里直接 fs.readFileSync 就能读文件——但 Agent 里需要多一层契约,让模型能用名字描述它想要什么。

+

平时写 Node.js 脚本,直接 import fs 调同步接口就行,代码知道要读哪个文件。Agent 的问题是:模型不能直接调函数,它只能输出文本。工具契约(名称 + JSON Schema)是模型和运行时之间的协议——模型在返回里写 read({"{"}"path":"docs/plan.md"{"}"},运行时解析出工具名和参数,调真正的执行函数,拿到结果再连同调用 ID 一起发回模型。对 Loop 来说,不管工具里面是 fs 读文件、子进程还是网络请求,外面的流程都一样:调用 → 等结果 → 追加历史。

@@ -50,56 +50,98 @@ export function ToolsSlide() {
; } -export function ProductSlide({ kind }: { kind: 'opencode-shell' | 'opencode-question' | 'codex' }) { - const content: Record = { - 'opencode-shell': { - title: 'OpenCode 把退出结果写回会话', - subtitle: '后台分支先回执,退出后用 sessions.synthetic 交付。', - sources: opencodeShell, - note: <>开启实验选项后,超过前台等待预算的同一进程可以转到后台,复用完成通知。 OpenCode #63024 ↗, - }, - 'opencode-question': { - title: 'AiWork 的问题卡在哪里等', - subtitle: 'OpenCode 1.18.18 发布问题事件,再等同一个 Deferred 被解除。', - sources: opencodeQuestion, - note: <>网页答复通过 requestID 找回等待中的调用。, - }, - codex: { - title: 'Codex 把答复作为用户消息送回', - subtitle: '提问先结算,后续请求用 questionItemId 关联用户答复。', - sources: codexAsync, - note: <>右侧是上游测试记录的模型请求。accepted 回执和用户答复分别出现。, - }, - }; - const { title, subtitle, sources, note } = content[kind]; - const explanation = { - 'opencode-shell': '左侧后台分支先注册完成通知,然后返回后台回执。右侧通知逻辑会等待任务结束,拿到退出状态和输出,再把它们写入发起任务的会话。这与刚才演示中的 job_id、进程退出事件和收件箱投递一一对应。', - 'opencode-question': 'ask 发布问题后,通过 Deferred.await 等待答案。reply 根据 requestID 找到这个等待对象,再用 Deferred.succeed 提交答案。因此问题卡的交互虽然发生在网页上,模型的工具调用仍要等这一步解除后才能完成。', - 'codex': '工具源码先向界面发出带问题的消息,再立即返回 accepted。右侧测试快照显示模型已经继续写了一段草稿,用户的回答随后作为 role 为 user 的新消息出现。questionItemId 将这份回答关联回原来的问题。', - }[kind]; - return
-

{title}

{subtitle}

-

{explanation}

-
{sources.map((source) => )}
-

{note}

+const compareRows: { product: string; where: string; longCmd: { label: string; detail: string }; question: { label: string; detail: string }; note: string }[] = [ + { + product: 'AiWork', + where: 'OpenCode 1.18.18', + longCmd: { label: '后台 Shell(V2 实验性)', detail: 'V2 有后台分支,超过前台等待预算自动转后台,退出结果用 sessions.synthetic 写回会话。旧版同步等待。' }, + question: { label: '阻塞等待', detail: 'question.ask 通过 Deferred.await 挂住工具调用,网页 reply 触发 Deferred.succeed 才解除。等待期间 Loop 不动,请求数停在当前值。' }, + note: 'AiWork 今天用的是第 3 级。Loop 能接收「立即插入」的消息,但只要 question 还没 resolve,模型就看不到。', + }, + { + product: 'Codex CLI', + where: 'rust-v0.167.1', + longCmd: { label: '超时转后台', detail: 'exec_command 超过前台等待后返回 process_id,进程继续运行。退出事件作为独立消息送回原 turn。' }, + question: { label: '异步回执', detail: 'request_user_input_async 把问题发到界面后立即返回 {"accepted":true}。用户答复带 questionItemId 作为 user 消息进入下一次请求,模型可以继续工作。' }, + note: 'Codex 是第 4 级的直接对照。测试快照里能看到:回执在请求 N,用户答复在请求 N+2,中间已有模型输出。', + }, + { + product: 'Claude Code', + where: 'Anthropic SDK', + longCmd: { label: 'Ctrl+B 转后台', detail: '前台工具超过预算自动转后台,也可以手动 Ctrl+B。后台任务有独立生命周期,完成后通知原会话。' }, + question: { label: '阻塞回调', detail: 'SDK 的 AskUserQuestion 走 canUseTool 回调,本质是同步等用户操作。等待期间 Loop 挂起,和 OpenCode 的 Deferred.await 类似。' }, + note: '长命令处理和 Codex 类似,提问机制和 OpenCode 类似。两种能力各自独立。', + }, + { + product: 'OpenAI Responses API', + where: 'API 层', + longCmd: { label: '原生异步工具', detail: '工具定义里加 async: true,API 层管理生命周期。结果作为后续的 function_call_output 绑回原 call_id,不走普通消息通道。' }, + question: { label: '同一机制', detail: '用户输入工具也用 async: true。答案通过 function_call_output 绑回,不会被模型伪造(结果不走消息通道)。' }, + note: '把异步性做进 API 协议里,从根本上解决了模型伪造通知的问题。代价是绑定了特定 API。', + }, +]; + +export function CompareSlide() { + const [expanded, setExpanded] = useState(null); + return
+
+

行业怎么处理这两个问题

+

长命令不能同步等,提问不能阻塞 Loop——各家的方案殊途同归,底层逻辑都是一样的。

+
+

把四个场景跑完之后,再看这张表就会清楚:每个产品做的无非是把「发起」和「拿结果」在不同层次拆开。拆的越早、越深,Loop 能重叠的工作就越多。OpenAI 把它做进了 API 协议,其他产品在 SDK 或运行时层面实现。AiWork 目前在第 3 级,机制上离第 4 级只差把 question 的 await 提前 resolve。

+
+
+
产品
+
长命令处理
+
用户提问
+
+ {compareRows.map((row) => { + const key = row.product; + const open = expanded === key; + return
+
+
+ {row.product} + {row.where} +
+ + +
+ {open &&
+
+
+

{row.longCmd.detail}

+
+
+

{row.question.detail}

+
+
} +
{row.note}
+
; + })} +
; } export function ReturnSlide() { return
-

后台工作如何回到 Loop

运行时拿到结果,按 ID 归属,在下一次可用的模型请求里交付。

-

后台工作可以发生在本地子进程、另一个 Agent,或远端服务中。运行时负责保存任务与会话的对应关系,收到完成结果后将它排入收件箱。Loop 在可以接收新消息的边界取走这些结果,追加到历史后再请求模型。后台任务的生命周期和模型的每次请求因此可以交错推进。

+

收件箱统一了三条路径

后台进程、子 Agent、用户答复——对 Loop 来说都是同一件事:一条消息在步骤边界进入历史。

+

这次原型里,后台 Bash 的退出通知、异步提问的用户答复、用户插话,走的是同一个收件箱,进入模型的时机规则也完全一样:Loop 活跃时等到步骤边界取走,Loop 空闲时收件箱一有东西就唤醒新的一轮。这意味着把机制做对了一次,三种来源就都能用。

-
本地进程退出码与输出
+
本地进程退出码与输出,带 job_id
子 Agent独立 Loop 结束后报告
-
远端任务轮询结果或收到回调
+
远端任务轮询或回调,带任务 ID
-
运行时收件箱用任务 ID 找回发起会话
+
运行时收件箱用 ID 找回发起会话,步骤边界投递
-
模型下一步活跃时接入,空闲时唤醒
+
模型下一步活跃时接入,空闲时唤醒新一轮
-

AiWork 的「立即插入」也要等可接收新消息的边界。若 Loop 还在等阻塞式 question,先由答复解除等待。

+

回到 AiWork:「立即插入」能把消息收进来,但只要 question 工具还在 await answer,Loop 就不动,消息得等答复解除那个 await 之后才被模型看到。从第 3 级到第 4 级的差别,就是把这个 await 提前 resolve——把「发问题」和「拿答案」拆成两件事。

; } diff --git a/async-agent-lab/web/src/stage/content.ts b/async-agent-lab/web/src/stage/content.ts index 82ae1f3..d508bc7 100644 --- a/async-agent-lab/web/src/stage/content.ts +++ b/async-agent-lab/web/src/stage/content.ts @@ -158,12 +158,10 @@ export const pages = [ { slug: 'start', title: '为什么能边问边做' }, { slug: 'loop', title: 'Agent Loop 的基本逻辑' }, { slug: 'tools', title: '四个基础工具' }, - { slug: 'sync', title: '同步 Bash' }, - { slug: 'background', title: '后台 Bash' }, - { slug: 'opencode-shell', title: 'OpenCode 后台 Shell' }, - { slug: 'blocking', title: '阻塞提问' }, - { slug: 'opencode-question', title: 'AiWork 的问答实现' }, - { slug: 'async', title: '异步提问' }, - { slug: 'codex', title: 'Codex 的问答实现' }, - { slug: 'return', title: '后台结果如何回到 Loop' }, + { slug: 'sync', title: '第 1 级:同步 Bash' }, + { slug: 'background', title: '第 2 级:后台 Bash' }, + { slug: 'blocking', title: '第 3 级:阻塞提问' }, + { slug: 'async', title: '第 4 级:异步提问' }, + { slug: 'compare', title: '行业对比:各家怎么处理' }, + { slug: 'return', title: '收件箱统一了三条路径' }, ] as const; diff --git a/async-agent-lab/web/src/stage/main.tsx b/async-agent-lab/web/src/stage/main.tsx index fb91408..eb6743f 100644 --- a/async-agent-lab/web/src/stage/main.tsx +++ b/async-agent-lab/web/src/stage/main.tsx @@ -4,7 +4,7 @@ import type { ScenarioId, ScenarioInfo } from '../../../live/types.ts'; import { LoopLesson } from './Lesson.tsx'; import { Demo } from './Demo.tsx'; import { Dialog } from './Detail.tsx'; -import { Intro, ProductSlide, ReturnSlide, ToolsSlide } from './Slides.tsx'; +import { CompareSlide, Intro, ReturnSlide, ToolsSlide } from './Slides.tsx'; import { pages } from './content.ts'; import { getConfig } from './live.ts'; import './stage.css'; @@ -80,7 +80,7 @@ function App() { {slug === 'start' && go(page + 1)} />} {slug === 'loop' && } {slug === 'tools' && } - {(slug === 'opencode-shell' || slug === 'opencode-question' || slug === 'codex') && } + {slug === 'compare' && } {slug === 'return' && } {scenarioIds.map((scenario) =>