重构分享站:统一说明文字、合并行业对比页、移除旧 explainers
- 页面从 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
This commit is contained in:
@@ -199,9 +199,12 @@ export function Demo({ scenario, config, available, active }: { scenario: Scenar
|
||||
<div className="status" role="status"><span className={`signal-dot ${running ? 'live' : terminal && current.state === 'done' ? 'success' : ''}`} />{status}</div>
|
||||
<span className="request-count">请求 <b>{request?.n ?? 0}</b></span>
|
||||
</header>
|
||||
<div className="signals" aria-label="关键事件">
|
||||
{signalsNow.map(({ label, event }) => <button key={label} className={`signal ${event ? 'seen' : ''}`}
|
||||
disabled={!event} onClick={() => open(event)}><span>{label}</span><time>{event ? time(event.t) : '—'}</time></button>)}
|
||||
<div className="signals-wrapper">
|
||||
<span className="signals-label">这一级的关键时刻</span>
|
||||
<div className="signals" aria-label="关键事件">
|
||||
{signalsNow.map(({ label, event }) => <button key={label} className={`signal ${event ? 'seen' : ''}`}
|
||||
disabled={!event} onClick={() => open(event)}><span>{label}</span><time>{event ? time(event.t) : '—'}</time></button>)}
|
||||
</div>
|
||||
</div>
|
||||
{error || current?.state === 'error' ? <div className="trace-error" role="alert">{error || current?.note}</div> : null}
|
||||
{(report || current?.summary) && <div className="trace-output">
|
||||
|
||||
@@ -2,40 +2,40 @@ import type { ScenarioId } from '../../../live/types.ts';
|
||||
|
||||
const lessons: Record<ScenarioId, { intro: string; steps: [string, string][]; observation: string }> = {
|
||||
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 行输出,包成 <background_job_done> 消息放进收件箱。用户插话走同一个收件箱,优先级规则也一样。'],
|
||||
['步骤边界取走结果', '每次步骤结束,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', '你提交后,服务端把 <question_reply question_id="q_1"> 包成一条 user 消息放进收件箱。模型在下一次请求里看到这条消息,能认出这是哪道题的答案。'],
|
||||
['在依赖答案的步骤汇合', '候选工作项归纳完成后,waitForAnswer 等齐两项回答,write 才把本周选中的工作和下周计划写入周报。两份回答的内容各自出现在周报对应的段落里,证明答案真的被用上了。'],
|
||||
],
|
||||
observation: '先让问题卡保持待答,观察取证对话继续出现。随后答复计划、确认保留项,并核对写出的 weekly-report.md。',
|
||||
observation: '先让问题卡保持待答,观察右侧取证对话继续出现、请求数继续增加。再提交下周计划,然后勾选本周保留项。最后打开周报,核对两次回答各自出现在哪一段。',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -51,24 +51,23 @@ export function Lesson({ scenario }: { scenario: ScenarioId }) {
|
||||
export function LoopLesson() {
|
||||
return <section className="slide explain loop-lesson">
|
||||
<div className="slide-heading"><h1>最基础的 Agent Loop 是什么?</h1>
|
||||
<p>一个围绕消息历史重复执行的程序,让模型根据工具的真实结果决定下一步。</p></div>
|
||||
<p>这不是伪代码。下面这段 15 行的循环接的是真实的 DeepSeek 网关,跑出来的请求和返回都是真的。OpenCode、Codex、AiWork 用的 Agent 都是同一种结构的变体——区别只在工具怎么返回、Loop 怎么等。</p></div>
|
||||
<div className="reading-grid">
|
||||
<article className="lesson">
|
||||
<h2>模型负责提出下一步,运行时负责执行</h2>
|
||||
<p>用户提出任务后,运行时把任务、已有对话和工具定义发给模型。模型可能直接回答,也可能返回一个工具调用,例如让 Bash 查询 Git 提交。</p>
|
||||
<p>收到工具调用后,程序找到对应的执行函数,把参数交给它。执行结果连同调用 ID 一起追加到消息历史,模型才能在下一次请求中使用这些新信息。</p>
|
||||
<p>只要模型继续请求工具,这个过程就继续重复。模型给出最终文本时,这一轮可以结束。如果之后有后台结果或用户答复到达,运行时可以再次启动一轮。</p>
|
||||
<h2>“等待”发生在哪里?</h2>
|
||||
<p>同步工具会在执行完成后返回,因此 Loop 的下一步要等待它。异步工具先返回启动回执,运行时再通过后续消息交付结果。接下来的四个案例逐步展示这两种时序。</p>
|
||||
<h2>模型提出下一步,运行时执行</h2>
|
||||
<p>把任务、已有对话和工具定义发给模型。模型可能直接给文本答复,也可能返回一个 tool_calls,里面带着工具名、参数和一个 call_id。运行时按工具名查到对应的执行函数(就是普通的 async 函数),把参数传进去,拿到结果。</p>
|
||||
<p>结果连同原来那个 call_id 一起,以 role: "tool" 的形式追加进消息历史。下一次请求模型时,模型就能读到这份结果,决定下一步。只要模型还想要工具,这个循环就继续;模型给出纯文本时,这一轮就结束。</p>
|
||||
<h2>"等待"到底发生在哪里</h2>
|
||||
<p>关键就在 await runTool()。这是一个普通的 await,Node.js 事件循环没被卡死——网页、本地服务、收件箱照常工作。但是 Loop 的代码停在这一行,下一个 callModel() 发不出去。同步工具要等命令跑完才返回;异步工具先还回执,Loop 就能继续。后面四个案例,改的都是这一处什么时候 resolve。</p>
|
||||
</article>
|
||||
<div className="loop-steps">
|
||||
{[
|
||||
['用户任务与消息历史', '历史保存模型已经知道的任务、对话和工具结果。'],
|
||||
['请求模型', '把消息历史和可调用的工具定义发送给模型。'],
|
||||
['执行模型请求的工具', '按工具名分发,将参数传给真实的执行函数。'],
|
||||
['结果追加回历史', '保留调用 ID,再请求模型,直到这一轮结束。'],
|
||||
['用户任务与消息历史', '历史里保存任务、已发生的对话和已拿到的工具结果。每次请求把它整包发给模型。'],
|
||||
['请求模型', '把消息历史和这次能用的工具定义一起发送。模型返回文本或 tool_calls。'],
|
||||
['执行模型请求的工具', '按工具名查到执行函数,传参数进去。这里是唯一会卡住的地方——同步工具要等结果,异步工具先还回执。'],
|
||||
['结果追加回历史', '保留 call_id,以 role: "tool" 追加进历史,再请求模型。模型不再要工具时这一轮结束。'],
|
||||
].map(([title, body], i) => <div key={title}><span className="loop-step-number">{i + 1}</span><section><h3>{title}</h3><p>{body}</p></section></div>)}
|
||||
<p className="loop-return">工具结果回到第 2 步,形成 Loop。</p>
|
||||
<p className="loop-return">工具结果回到第 2 步,形成 Loop。后台结果和用户答复也一样,都从收件箱进入第 2 步。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>;
|
||||
|
||||
@@ -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 <section className="intro slide" aria-labelledby="intro-title">
|
||||
<div className="intro-copy">
|
||||
<h1 id="intro-title">Agent 为什么能<span>边问边做</span></h1>
|
||||
<p>问题卡还在等答复,Agent 已经继续查询代码。这次分享从最基础的 Agent Loop 讲起,逐步观察工具何时返回,以及稍后到达的结果怎样被模型接收。</p>
|
||||
<button className="button primary intro-next" onClick={next}>开始分享 <span aria-hidden="true">→</span></button>
|
||||
<p>AiWork 里问一个问题,模型就停下来等你回答,这期间什么都不做。Codex 不是这样——问题卡还挂着,取证的请求已经发出去了。这个差别不来自框架有多复杂,只来自工具调用什么时候返回。</p>
|
||||
<p>这次分享从一个 15 行的真实 Agent 原型开始,把机制一步步拆开。四个级别,每级只改一件事,每次都能看到请求时序真正变化的那个点。</p>
|
||||
<button className="button primary intro-next" onClick={next}>开始 <span aria-hidden="true">→</span></button>
|
||||
</div>
|
||||
<div className="intro-figure" aria-label="问题仍待回答,Agent 同时在取证">
|
||||
<div className="intro-ask"><span className="signal-dot" /> 问题待答 <code>q_1</code>
|
||||
<strong>下周优先推进什么?</strong>
|
||||
</div>
|
||||
<div className="intro-flow" aria-hidden="true"><span /><i /><span /></div>
|
||||
<div className="intro-work"><span className="signal-dot" /> Agent 正在取证 <code>请求 #2</code>
|
||||
<div className="intro-work"><span className="signal-dot" /> Agent 正在取证 <code>请求 #3</code>
|
||||
<strong>从本地会话定位活跃仓库</strong>
|
||||
</div>
|
||||
<div className="intro-note">AiWork 的阻塞提问 · OpenCode 的后台 Shell · Codex 的异步提问</div>
|
||||
<div className="intro-note">问题挂着,取证继续——今天 AiWork 做不到,这次分享讲为什么</div>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -26,8 +26,8 @@ export function Intro({ next }: { next: () => void }) {
|
||||
export function ToolsSlide() {
|
||||
const [execution, setExecution] = useState(false);
|
||||
return <section className="slide explain" aria-labelledby="tools-title">
|
||||
<div className="slide-heading"><h1 id="tools-title">先给 Agent 四个基础工具</h1><p>模型说要调用什么,运行时执行,再用调用 ID 交回结果。</p></div>
|
||||
<p className="section-prose">工具定义告诉模型它能做什么,以及需要提供哪些参数。执行函数负责实际读取文件或启动命令。模型返回一次 read 调用后,运行时会把文件内容作为工具结果送回;调用 ID 让模型知道这份结果属于之前的哪一次请求。</p>
|
||||
<div className="slide-heading"><h1 id="tools-title">先给 Agent 四个基础工具</h1><p>在 Node.js 里直接 fs.readFileSync 就能读文件——但 Agent 里需要多一层契约,让模型能用名字描述它想要什么。</p></div>
|
||||
<p className="section-prose">平时写 Node.js 脚本,直接 import fs 调同步接口就行,代码知道要读哪个文件。Agent 的问题是:模型不能直接调函数,它只能输出文本。工具契约(名称 + JSON Schema)是模型和运行时之间的协议——模型在返回里写 <code>read({"{"}"path":"docs/plan.md"{"}"}</code>,运行时解析出工具名和参数,调真正的执行函数,拿到结果再连同调用 ID 一起发回模型。对 Loop 来说,不管工具里面是 fs 读文件、子进程还是网络请求,外面的流程都一样:调用 → 等结果 → 追加历史。</p>
|
||||
<div className="tools-grid">
|
||||
<div className="tool-example">
|
||||
<div className="tabs" aria-label="read 的工具契约">
|
||||
@@ -50,56 +50,98 @@ export function ToolsSlide() {
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function ProductSlide({ kind }: { kind: 'opencode-shell' | 'opencode-question' | 'codex' }) {
|
||||
const content: Record<typeof kind, { title: string; subtitle: string; sources: Source[]; note: React.ReactNode }> = {
|
||||
'opencode-shell': {
|
||||
title: 'OpenCode 把退出结果写回会话',
|
||||
subtitle: '后台分支先回执,退出后用 sessions.synthetic 交付。',
|
||||
sources: opencodeShell,
|
||||
note: <>开启实验选项后,超过前台等待预算的同一进程可以转到后台,复用完成通知。 <a href="https://github.com/anomalyco/opencode/pull/63024" target="_blank" rel="noreferrer">OpenCode #63024 ↗</a></>,
|
||||
},
|
||||
'opencode-question': {
|
||||
title: 'AiWork 的问题卡在哪里等',
|
||||
subtitle: 'OpenCode 1.18.18 发布问题事件,再等同一个 Deferred 被解除。',
|
||||
sources: opencodeQuestion,
|
||||
note: <>网页答复通过 <code>requestID</code> 找回等待中的调用。</>,
|
||||
},
|
||||
codex: {
|
||||
title: 'Codex 把答复作为用户消息送回',
|
||||
subtitle: '提问先结算,后续请求用 questionItemId 关联用户答复。',
|
||||
sources: codexAsync,
|
||||
note: <>右侧是上游测试记录的模型请求。<code>accepted</code> 回执和用户答复分别出现。</>,
|
||||
},
|
||||
};
|
||||
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 <section className="slide explain" aria-labelledby="product-title">
|
||||
<div className="slide-heading"><h1 id="product-title">{title}</h1><p>{subtitle}</p></div>
|
||||
<p className="section-prose">{explanation}</p>
|
||||
<div className="compare-grid">{sources.map((source) => <Code key={source.file} source={source} />)}</div>
|
||||
<p className="speaker-note">{note}</p>
|
||||
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<string | null>(null);
|
||||
return <section className="slide explain" aria-labelledby="compare-title">
|
||||
<div className="slide-heading">
|
||||
<h1 id="compare-title">行业怎么处理这两个问题</h1>
|
||||
<p>长命令不能同步等,提问不能阻塞 Loop——各家的方案殊途同归,底层逻辑都是一样的。</p>
|
||||
</div>
|
||||
<p className="section-prose">把四个场景跑完之后,再看这张表就会清楚:每个产品做的无非是把「发起」和「拿结果」在不同层次拆开。拆的越早、越深,Loop 能重叠的工作就越多。OpenAI 把它做进了 API 协议,其他产品在 SDK 或运行时层面实现。AiWork 目前在第 3 级,机制上离第 4 级只差把 question 的 await 提前 resolve。</p>
|
||||
<div className="compare-table" role="table" aria-label="各产品异步机制对比">
|
||||
<div className="ct-head" role="row">
|
||||
<div role="columnheader">产品</div>
|
||||
<div role="columnheader">长命令处理</div>
|
||||
<div role="columnheader">用户提问</div>
|
||||
</div>
|
||||
{compareRows.map((row) => {
|
||||
const key = row.product;
|
||||
const open = expanded === key;
|
||||
return <div key={key} className={`ct-row ${open ? 'ct-row-open' : ''}`} role="rowgroup">
|
||||
<div className="ct-row-main" role="row">
|
||||
<div role="cell" className="ct-product">
|
||||
<strong>{row.product}</strong>
|
||||
<span>{row.where}</span>
|
||||
</div>
|
||||
<button role="cell" className={`ct-cell ${open ? 'active' : ''}`} onClick={() => setExpanded(open ? null : key)} aria-expanded={open}>
|
||||
<span className="ct-label">{row.longCmd.label}</span>
|
||||
</button>
|
||||
<button role="cell" className={`ct-cell ${open ? 'active' : ''}`} onClick={() => setExpanded(open ? null : key)} aria-expanded={open}>
|
||||
<span className="ct-label">{row.question.label}</span>
|
||||
</button>
|
||||
</div>
|
||||
{open && <div className="ct-detail" role="row">
|
||||
<div role="cell" />
|
||||
<div role="cell" className="ct-detail-cell">
|
||||
<p>{row.longCmd.detail}</p>
|
||||
</div>
|
||||
<div role="cell" className="ct-detail-cell">
|
||||
<p>{row.question.detail}</p>
|
||||
</div>
|
||||
</div>}
|
||||
<div className="ct-note" role="row"><div role="cell" /><div role="cell" className="ct-note-text">{row.note}</div></div>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function ReturnSlide() {
|
||||
return <section className="slide explain return-slide" aria-labelledby="return-title">
|
||||
<div className="slide-heading"><h1 id="return-title">后台工作如何回到 Loop</h1><p>运行时拿到结果,按 ID 归属,在下一次可用的模型请求里交付。</p></div>
|
||||
<p className="section-prose">后台工作可以发生在本地子进程、另一个 Agent,或远端服务中。运行时负责保存任务与会话的对应关系,收到完成结果后将它排入收件箱。Loop 在可以接收新消息的边界取走这些结果,追加到历史后再请求模型。后台任务的生命周期和模型的每次请求因此可以交错推进。</p>
|
||||
<div className="slide-heading"><h1 id="return-title">收件箱统一了三条路径</h1><p>后台进程、子 Agent、用户答复——对 Loop 来说都是同一件事:一条消息在步骤边界进入历史。</p></div>
|
||||
<p className="section-prose">这次原型里,后台 Bash 的退出通知、异步提问的用户答复、用户插话,走的是同一个收件箱,进入模型的时机规则也完全一样:Loop 活跃时等到步骤边界取走,Loop 空闲时收件箱一有东西就唤醒新的一轮。这意味着把机制做对了一次,三种来源就都能用。</p>
|
||||
<div className="return-diagram" aria-label="本地进程、子 Agent、远端任务将结果交给运行时,随后回到模型">
|
||||
<div className="return-sources">
|
||||
<div><strong>本地进程</strong><span>退出码与输出</span></div>
|
||||
<div><strong>本地进程</strong><span>退出码与输出,带 job_id</span></div>
|
||||
<div><strong>子 Agent</strong><span>独立 Loop 结束后报告</span></div>
|
||||
<div><strong>远端任务</strong><span>轮询结果或收到回调</span></div>
|
||||
<div><strong>远端任务</strong><span>轮询或回调,带任务 ID</span></div>
|
||||
</div>
|
||||
<div className="diagram-arrow" aria-hidden="true">→</div>
|
||||
<div className="return-node"><strong>运行时收件箱</strong><span>用任务 ID 找回发起会话</span></div>
|
||||
<div className="return-node"><strong>运行时收件箱</strong><span>用 ID 找回发起会话,步骤边界投递</span></div>
|
||||
<div className="diagram-arrow" aria-hidden="true">→</div>
|
||||
<div className="return-node last"><strong>模型下一步</strong><span>活跃时接入,空闲时唤醒</span></div>
|
||||
<div className="return-node last"><strong>模型下一步</strong><span>活跃时接入,空闲时唤醒新一轮</span></div>
|
||||
</div>
|
||||
<p className="speaker-note">AiWork 的「立即插入」也要等可接收新消息的边界。若 Loop 还在等阻塞式 question,先由答复解除等待。</p>
|
||||
<p className="speaker-note">回到 AiWork:「立即插入」能把消息收进来,但只要 question 工具还在 await answer,Loop 就不动,消息得等答复解除那个 await 之后才被模型看到。从第 3 级到第 4 级的差别,就是把这个 await 提前 resolve——把「发问题」和「拿答案」拆成两件事。</p>
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' && <Intro next={() => go(page + 1)} />}
|
||||
{slug === 'loop' && <LoopLesson />}
|
||||
{slug === 'tools' && <ToolsSlide />}
|
||||
{(slug === 'opencode-shell' || slug === 'opencode-question' || slug === 'codex') && <ProductSlide kind={slug} />}
|
||||
{slug === 'compare' && <CompareSlide />}
|
||||
{slug === 'return' && <ReturnSlide />}
|
||||
{scenarioIds.map((scenario) => <div className="demo-page" key={scenario} hidden={slug !== scenario}>
|
||||
<Demo scenario={scenario} config={config?.find((info) => info.id === scenario)} available={Boolean(config)} active={slug === scenario} />
|
||||
|
||||
@@ -431,6 +431,39 @@ body { line-height: 1.7; }
|
||||
*, *::before, *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
|
||||
.signals-wrapper { border-bottom: 1px solid var(--line); }
|
||||
.signals-label { display: block; padding: 8px 13px 0; font-size: 11px; font-weight: 600; color: var(--terracotta); text-transform: uppercase; letter-spacing: .06em; }
|
||||
.signals { border-bottom: 0; }
|
||||
|
||||
/* Compare table */
|
||||
.compare-table { display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
|
||||
.ct-head { display: grid; grid-template-columns: 160px 1fr 1fr; gap: 0; background: #f5f4f0; border-bottom: 1px solid var(--line); }
|
||||
.ct-head > div { padding: 10px 16px; font-size: 12px; font-weight: 600; color: var(--muted); letter-spacing: .04em; text-transform: uppercase; }
|
||||
.ct-head > div:not(:last-child) { border-right: 1px solid var(--line); }
|
||||
.ct-row { border-bottom: 1px solid var(--line); }
|
||||
.ct-row:last-child { border-bottom: 0; }
|
||||
.ct-row-main { display: grid; grid-template-columns: 160px 1fr 1fr; }
|
||||
.ct-product { display: flex; flex-direction: column; gap: 3px; padding: 14px 16px; border-right: 1px solid var(--line); justify-content: center; }
|
||||
.ct-product strong { font-size: 14px; font-weight: 600; }
|
||||
.ct-product span { font-size: 12px; color: var(--muted); font-family: var(--mono); }
|
||||
.ct-cell {
|
||||
display: flex; align-items: center; gap: 8px; padding: 14px 16px;
|
||||
background: transparent; border: 0; border-right: 1px solid var(--line);
|
||||
text-align: left; cursor: pointer; transition: background var(--transition);
|
||||
}
|
||||
.ct-cell:last-child { border-right: 0; }
|
||||
.ct-cell:hover { background: #f7f2eb; }
|
||||
.ct-cell.active { background: var(--tint); }
|
||||
.ct-label { font-size: 14px; font-weight: 500; color: var(--ink); line-height: 1.4; }
|
||||
.ct-detail { display: grid; grid-template-columns: 160px 1fr 1fr; background: #faf7f3; border-top: 1px solid #ebe0d6; }
|
||||
.ct-detail > div:first-child { border-right: 1px solid var(--line); }
|
||||
.ct-detail-cell { padding: 12px 16px; border-right: 1px solid var(--line); }
|
||||
.ct-detail-cell:last-child { border-right: 0; }
|
||||
.ct-detail-cell p { font-size: 13px; line-height: 1.75; color: var(--muted); margin: 0; }
|
||||
.ct-note { display: grid; grid-template-columns: 160px 1fr; border-top: 1px dashed #e0d6cb; }
|
||||
.ct-note > div:first-child { border-right: 1px solid var(--line); }
|
||||
.ct-note-text { grid-column: span 1; padding: 8px 16px 12px; font-size: 12px; line-height: 1.7; color: var(--muted); border-left: 3px solid var(--terracotta); margin: 8px 16px; padding: 4px 10px; border-radius: 0; background: transparent; }
|
||||
|
||||
.section-prose { max-width: 86ch; font-size: 16px; line-height: 1.85; color: var(--muted); }
|
||||
|
||||
.deck-foot { flex-wrap: wrap; }
|
||||
|
||||
Reference in New Issue
Block a user