推理过程与共享状态:可折叠的思考区和增量同步
先把模型的推理过程做成一个默认收起、可以展开的区域,再引入 Agent 前端真正区别于聊天界面的东西:一份前后端共享的状态,用整份快照打底、用 JSON Patch 增量更新,让界面能显示聊天记录之外的进展。
今日目标
- 能把推理事件渲染成可折叠区域,并说出推理内容为什么要与正式回答区分开
- 能用快照加增量的方式同步共享状态,说清两者各自的适用时机
- 能应用 JSON Patch 的增量,并处理补丁与本地状态不匹配时的恢复策略
昨天做的是「看见它要做什么」。今天做两件更进一步的事:看见它在想什么,以及看见它手里那份数据。读完回到页面顶部把三条目标勾掉。
小白版讲解
译员的草稿纸:给看,但不能挡住正式翻译
好的同传译员手边都有一张草稿纸,上面是听写的数字、临时记的术语、还没想好怎么译的半句话。这张纸对听众有价值——如果译员卡住了,你瞟一眼草稿纸就知道他卡在哪个词上,而不是怀疑设备坏了。
但没有哪个会场会把草稿纸投到大屏幕上。因为上面有大量的涂改、试错、被划掉的错误译法。观众如果盯着它看,会被中间过程误导,以为那些划掉的就是译文。
推理过程就是这张草稿纸:值得给,但要收着给。
这一节的三个设计决定都从这个类比来:默认收起、流式期间给一个活的提示、和正式回答在视觉上明确区分。
推理事件与文本事件为什么不能混在一起
AG-UI 给推理单独开了一组事件,和文本消息的三段式结构一模一样:
| 推理 | 文本消息 |
|---|---|
REASONING_START | TEXT_MESSAGE_START |
REASONING_MESSAGE_CONTENT | TEXT_MESSAGE_CONTENT |
REASONING_END | TEXT_MESSAGE_END |
结构一样,为什么不复用同一组事件、加个标记区分?
因为混在一起之后,任何一个忘记检查标记的地方都会把推理当成答案显示出来。而推理内容里常有「等等,这个口径不对」「先假设是自然季度」这类试错和自我否定。把它渲染成正式回答,用户读到的是一个自相矛盾、来回改口的助手。
分成两组事件,等于用类型系统把这件事钉死:你的渲染代码里根本没有路径能让推理内容流进消息气泡。结构上做不到的错误,比靠纪律避免的错误可靠得多。
默认收起,还是默认展开
这是个会直接影响信任感的小决定,而且没有唯一答案,取决于你的场景。
默认收起(本课的选择):推理是过程不是结论,多数用户多数时候只想要答案。默认展开会把正式回答挤到屏幕外,用户得往下滚才能看到自己真正要的东西。
默认展开:适合调试场景,或者推理本身就是产品价值的一部分(比如数学解题、代码审查工具)。
不管选哪个,有两件事都要做:
流式期间必须给一个活的提示。 推理往往是整次运行里最长的一段静默,可能持续十几秒。如果折叠标题一动不动,用户会以为卡住了。本课的做法是在标题上显示「正在思考…(已 137 字)」,那个数字在涨,用户就知道它在动。
视觉上要和正式回答明确区分。 用更浅的颜色、更小的字号、一道左边框——让读者一眼看出「这不是最终答案」。
共享状态:聊天记录之外的那部分进展
现在到今天更重要的一半。
想象 Agent 正在帮你整理一份季度报告。它做的事情里,有些适合说出来(「我先确认一下口径」),有些根本不适合塞进对话:
- 一个正在被逐项填写的表单
- 一份逐步成形的报告大纲
- 一个走到第三步的流程,前两步已打勾
- 一组随着查询不断补全的指标
这些东西的特点是会原地更新。如果做成聊天消息,你会得到十几条「已完成第 1 步」「已完成第 2 步」的刷屏,用户还得自己在脑子里拼出当前状态。
共享状态就是给这类信息的一块专门地方:前后端共同维护的一份数据,界面把它渲染成一个会原地更新的面板。这是 Agent 界面区别于聊天界面最本质的一点——聊天是追加的,状态是覆盖的。
快照打底,增量推进
协议给了两种更新方式:
// 快照:整份状态,权威
{ type: 'STATE_SNAPSHOT', snapshot: { task: '整理季度报告', steps: [...], metrics: {} } }
// 增量:只发变化的部分,是一组 JSON Patch 操作
{ type: 'STATE_DELTA', delta: [
{ op: 'replace', path: '/steps/1/done', value: true },
{ op: 'add', path: '/metrics/revenue', value: 1284000 },
]}什么时候用哪个,有清楚的判据:
| 场合 | 用 | 理由 |
|---|---|---|
| 会话开始、界面第一次拿到状态 | 快照 | 没有底就没法打补丁 |
| 工作推进中的常规更新 | 增量 | 状态可能很大,每次重发浪费带宽 |
| 补丁打不上、检测到不一致 | 快照 | 唯一可靠的重新对齐手段 |
| 用户刷新页面、重新连接 | 快照 | 同上 |
一条经验:增量是优化,快照是正确性的保障。 如果拿不准,多发一次快照永远是安全的;而少发一次快照可能让两端永久性地对不上。
JSON Patch 的两条工程性质
STATE_DELTA 里的操作遵循 RFC 6902。实现本身不复杂(本课手写了一份,零依赖),但有两条性质必须做对,它们才是真正的工程难点。
第一,全有或全无。
// 在副本上执行,中途任何一条失败就丢弃整个副本、返回原状态
export function applyPatch(state, operations) {
const draft = structuredClone(state)
try {
let current = draft
for (const op of operations) current = applyOne(current, op)
return { ok: true, state: current }
} catch (error) {
// 注意返回的是原状态,不是那个改了一半的副本
return { ok: false, error, state }
}
}一组补丁里第三条失败了,前两条也不能留下痕迹。因为半应用的状态比不应用更糟——你会得到一份服务端和客户端谁都没见过的数据,之后所有的补丁都建立在这份幻觉上。
第二,失败不抛异常。
补丁打不上是可预期的,不是程序 bug:丢包、乱序、服务端重启导致的版本漂移,都会造成这种情况。所以正确的签名是返回一个结果对象,让调用方决定怎么办,而不是往外抛异常让界面崩掉。
replace 到不存在的键:为什么必须失败
这是本课一个刻意做严格的地方,值得单独说。
规范规定 replace 的路径必须已存在。实现时很容易「宽容」一下:路径不在就当 add 处理,反正结果差不多。
不要这么做。 服务端发来 replace,说明它认为那个键存在,而你本地没有——这本身就是两边状态已经不一致的信号。悄悄补上等于把不一致藏起来,直到某天以更难查的形式爆出来:用户看到一份对不上的数据,而日志里什么都没有。
严格失败反而是好事:它把一个隐蔽的数据问题,变成一个明确的、可以立刻恢复的信号。
状态面板要不要让用户编辑
一个自然会被问到的产品问题:既然状态是「共享」的,用户能不能直接改它?
能,而且这正是共享状态最有价值的用法之一——用户发现 Agent 把季度口径搞错了,直接在面板上改成「财年」,比打字解释一遍快得多。
但要处理好一件事:用户的修改和服务端的补丁可能撞车。用户正在改某个字段,同时服务端发来一条改同一个字段的补丁,谁赢?
本课的建议是用户赢,并且立刻把修改发回服务端。理由是用户的意图是明确的,而模型的更新是推测性的。实现上就是乐观更新:本地立刻生效,同时发一个请求告诉服务端,服务端据此调整后续动作。
如果你的场景不适合让用户改,那就把面板明确做成只读的样子(没有输入框、没有可点的地方),不要做成看起来能改但改了没用。
打不上怎么办:标记不同步,等待重新对齐
有了前面的铺垫,恢复策略就很自然了:
const result = applyPatch(prev, event.delta)
if (!result.ok) {
setStateStale(true) // 界面上明确告诉用户:这份数据可能过期了
return prev // 保留旧数据,不显示半成品
}三个选项里为什么选这个:
- 抛异常让界面崩掉——用户丢掉整个会话,代价远大于一条补丁。
- 静默忽略,继续显示旧数据——用户看到一份可能是错的数据,而且不知道它是错的。这是最危险的一个,因为它看起来一切正常。
- 标记不同步并等重新对齐——用户知道这份数据暂时不可信,服务端补发快照后自动恢复。
界面上的表现是:面板照常显示旧数据,但顶上多一条「与服务端不同步,正在重新对齐…」。显示一份可能是错的数据,比显示「正在同步」更糟——这条判断在所有涉及数据同步的界面里都成立。
源码导读
动手实验
今天的 JSON Patch 要自己写,但不要急着追求完整实现——move 和 copy 在 Agent 场景里几乎用不到,本课直接不支持,遇到就走打不上的路径。把两条工程性质做对,比支持全部六种操作重要得多。
- 渲染推理事件为可折叠区域,流式期间显示进行中
- 接收状态快照并渲染成一个结构化面板
- 实现 JSON Patch 的应用逻辑,覆盖增删改三类操作
- 构造一次打不上的补丁,验证界面回退到请求快照
- 把状态面板与聊天流并排放进同一个布局
面试题
今天 4 道题在下方题库区,侧重推理内容的呈现取舍、快照与增量的权衡、状态不一致时的兜底。展开后先看"分析过程"再看要点——照着推导练,比背要点管用。标注"国内高频 / 海外高频"方便按目标市场取舍。
检查清单与明日预告
- 能把推理事件渲染成可折叠区域,并说出推理内容为什么要与正式回答区分开
- 能用快照加增量的方式同步共享状态,说清两者各自的适用时机
- 能应用 JSON Patch 的增量,并处理补丁与本地状态不匹配时的恢复策略
- 能说清为什么 replace 到不存在的键应该失败而不是宽容处理
- 实验的 6 条验收标准全部通过
- 4 道面试题不看要点也能答出至少 3 道
明天(D5)我们把控制权进一步交给模型:让它决定界面长什么样。生成式界面有三种范式,控制权从前端一路让渡到模型,最灵活的那种在生产里往往是最危险的。今天的共享状态其实已经是生成式界面的前身——当数据由模型决定时,下一个自然的问题就是「渲染它的组件能不能也由模型决定」。
面试题库
模型的推理过程要不要展示给用户?默认收起还是默认展开?说出你的理由。Should the model's reasoning be shown to users? Collapsed or expanded by default? Justify your choice.
国内高频海外高频进阶#reasoning-ui#ux#protocol-design分析过程 · 先想清楚再作答
- 这题没有唯一答案,考的是你能不能说出判断依据。直接答「收起」或「展开」而不给理由,等于没答。
- 先确立一个事实:推理内容里常有试错和自我否定,比如「等等,这个口径不对」「先假设是自然季度」。这决定了它不能和正式回答混在一起显示,否则用户读到的是一个来回改口的助手。
- 默认收起的理由:推理是过程不是结论,多数用户多数时候只想要答案。默认展开会把正式回答挤到屏幕外,用户还得往下滚才能看到自己真正要的东西。通用产品应该选这个。
- 默认展开的适用场景:调试工具,或者推理本身就是产品价值的一部分——数学解题、代码审查、诊断类工具,用户买的就是那个推导过程。
- 不管选哪个,有两件事都要做。一是流式期间必须给一个活的提示:推理往往是整次运行里最长的一段静默,可能十几秒,折叠标题一动不动用户会以为卡住了,所以要显示「正在思考…(已 N 字)」让那个数字涨起来。二是视觉上必须和正式回答明确区分,用更浅的颜色或一道左边框,让人一眼看出这不是最终答案。
- 可预期的追问是「协议层面怎么保证不混」——AG-UI 给推理单独开了一组事件而不是在文本消息上加标记。这样渲染代码里根本没有路径能让推理流进消息气泡,**结构上做不到的错误比靠纪律避免的错误可靠**。
How to reason about it · think before answering
- There is no single right answer here; the question tests whether you can articulate the criteria. Answering 'collapsed' or 'expanded' without reasoning scores nothing.
- Establish a fact first: reasoning contains false starts and self-correction, things like 'wait, that basis is wrong' or 'assume calendar quarters for now'. That alone rules out mixing it with the final answer, or the user reads an assistant that keeps contradicting itself.
- The case for collapsed by default: reasoning is process, not conclusion, and most users most of the time just want the answer. Expanding by default pushes the actual answer off-screen so they have to scroll for the thing they came for. General-purpose products should pick this.
- The case for expanded: debugging tools, or products where the reasoning is the value — math tutoring, code review, diagnostics. There the derivation is what the user is paying for.
- Either way, two things are mandatory. First, a live indicator during streaming: reasoning is often the longest silence in a run, easily ten seconds or more, and a static collapsed header reads as frozen. Show 'thinking... (N characters)' with a number that climbs. Second, visual separation from the final answer via lighter text or a left border, so nobody mistakes it for the conclusion.
- Expect the follow-up on how the protocol prevents mixing: AG-UI gives reasoning its own event group rather than a flag on text messages, so there is no code path that lets reasoning flow into a message bubble. **Errors made structurally impossible beat errors avoided by discipline.**
答题要点
- 推理含试错和自我否定,必须与正式回答分开,否则用户读到一个来回改口的助手。
- 通用产品默认收起:推理是过程不是结论,默认展开会把答案挤到屏幕外。
- 调试工具或推理本身即价值的产品(解题、审查、诊断)可以默认展开。
- 流式期间必须有活的提示(正在思考加字数),否则十几秒静默会被当成卡住。
- 协议层用独立事件组而不是加标记,让「推理流进消息气泡」在结构上不可能发生。
Key points
- Reasoning contains false starts, so it must be separated from the final answer.
- Collapse by default in general products: reasoning is process, and expanding pushes the answer off-screen.
- Expand by default for debugging tools or products where the derivation is the value.
- Always show a live indicator while streaming, or a ten-second silence reads as frozen.
- Use a separate event group rather than a flag, making the mixing error structurally impossible.
共享状态既可以每次发完整快照,也可以发增量补丁,你怎么决定用哪种?Shared state can be sent as full snapshots or as incremental patches. How do you decide which?
国内高频海外高频进阶#state-sync#protocol-design#architecture分析过程 · 先想清楚再作答
- 这题考的是你会不会给出判据,而不是罗列两者的优缺点。「快照简单、增量省流量」这种对称的罗列,面试官听完不知道你到底会怎么选。
- 给一条能落地的原则:**增量是优化,快照是正确性的保障**。拿不准的时候多发一次快照永远是安全的,而少发一次快照可能让两端永久性地对不上。
- 然后按场合分:会话开始、界面第一次拿到状态,必须用快照——没有底就没法打补丁;工作推进中的常规更新用增量,状态可能很大,每次重发浪费带宽;检测到补丁打不上、或者用户刷新页面重新连接,用快照重新对齐。
- 还有一个容易漏的场景值得主动提:**收到增量但本地根本没有快照**。这说明漏了开头,正确反应是标记不同步并请求快照,而不是凭空造一个空对象往上打补丁——那样会造出一份服务端从没见过的数据。
- 如果要展开成本讨论:增量的代价不只是带宽,还有实现复杂度和一整类新的失败模式(乱序、丢包、版本漂移)。状态本身很小的时候,一律发快照是完全合理的工程选择,不要为了显得先进而引入增量。
- 可预期的追问是「怎么知道两端对不上了」——补丁打不上就是最直接的信号,这也是为什么补丁的失败处理必须做对,见下一题。
How to reason about it · think before answering
- This asks for a decision rule, not a symmetric list of pros and cons. 'Snapshots are simple, patches save bandwidth' leaves the interviewer unsure what you would actually do.
- Offer an actionable principle: **patches are an optimization, snapshots are the correctness guarantee**. When in doubt, an extra snapshot is always safe, while a missing one can leave the two sides permanently out of sync.
- Then split by situation: session start and first load must be a snapshot, since there is nothing to patch; routine progress updates use patches because resending a large state is wasteful; and detecting a failed patch or reconnecting after a refresh calls for a snapshot to realign.
- One easily missed case worth raising unprompted: **receiving a patch with no local snapshot at all**. That means you missed the beginning. The right response is to flag desync and request a snapshot, not to invent an empty object and patch onto it, which fabricates state the server has never seen.
- If you want to go deeper on cost: patches cost more than bandwidth, they add implementation complexity and a whole class of failure modes (reordering, loss, version drift). When state is small, sending snapshots exclusively is a perfectly sound engineering choice.
- Expect the follow-up on detecting divergence: a patch that fails to apply is the most direct signal, which is why failure handling has to be right.
答题要点
- 原则是增量为优化、快照为正确性保障;拿不准时多发快照永远安全。
- 会话开始与重连用快照,工作推进中的常规更新用增量。
- 补丁打不上或检测到不一致时,用快照重新对齐。
- 收到增量但本地没有快照,说明漏了开头,要请求快照而不是凭空造一个空状态。
- 状态本身很小时一律发快照是合理选择,增量会带来乱序丢包漂移这一整类失败模式。
Key points
- The rule: patches optimize, snapshots guarantee correctness; an extra snapshot is always safe.
- Snapshot on session start and reconnect; patch for routine progress updates.
- Use a snapshot to realign whenever a patch fails or divergence is detected.
- A patch with no local snapshot means you missed the start: request a snapshot rather than inventing empty state.
- For small state, snapshots only is a sound choice; patches add reordering, loss, and drift as failure modes.
前端收到一个打不上的 JSON Patch,说明发生了什么?你的恢复策略是什么?Your frontend receives a JSON Patch that cannot be applied. What does that tell you, and how do you recover?
国内高频海外高频深入#state-sync#error-handling#json-patch分析过程 · 先想清楚再作答
- 这题的区分度在于你把打不上当成 bug 还是当成可预期事件。当成 bug 的人会答「加日志排查」,然后就没有恢复策略了。
- 先定性:补丁打不上是**可预期的**,不是程序错误。丢包、乱序、服务端重启导致的版本漂移都会造成这种情况。它的含义只有一个——**两端状态已经不一致了**。
- 所以实现上第一条要求是:`applyPatch` 失败时**返回结果而不是抛异常**,让调用方决定怎么办。抛异常等于把一个可恢复的同步问题升级成界面崩溃。
- 第二条要求是**全有或全无**:一组补丁里第三条失败了,前两条也不能留下痕迹。做法是在深拷贝的副本上执行,失败就整个丢弃、返回原状态。半应用的状态比不应用更糟——你会得到一份服务端和客户端谁都没见过的数据,之后所有补丁都建立在这份幻觉上。
- 恢复策略在三个选项里选:抛异常崩掉(用户丢掉整个会话,代价远大于一条补丁);静默忽略继续显示旧数据(最危险,用户看到可能错的数据而且不知道它错了);**标记不同步并等服务端补发快照重新对齐**(正确答案)。界面上保留旧数据但加一条明确提示,因为显示一份可能是错的数据比显示「正在同步」更糟。
- 可预期的追问是「replace 到不存在的键要不要宽容处理成 add」——不要。服务端发 replace 说明它认为那个键存在而本地没有,这本身就是不一致的信号,悄悄补上等于把问题藏到更难查的时候。严格失败反而把隐蔽的数据问题变成可立刻恢复的明确信号。
How to reason about it · think before answering
- The discriminator is whether you treat a failed patch as a bug or as an expected event. People who see it as a bug answer 'add logging' and then have no recovery story.
- Characterize it first: failed patches are **expected**, not programming errors. Packet loss, reordering, and version drift after a server restart all cause them. The meaning is singular — **the two sides have diverged**.
- So requirement one: `applyPatch` must **return a result rather than throw**, letting the caller decide. Throwing escalates a recoverable sync problem into a crashed interface.
- Requirement two is **all or nothing**: if the third operation in a batch fails, the first two must leave no trace. Apply to a deep-cloned draft and discard the whole draft on failure, returning the original. A half-applied state is worse than none, because it is data neither side has ever seen, and every subsequent patch builds on that fiction.
- For recovery, weigh three options: throwing and crashing (the user loses the whole session over one patch); silently ignoring and showing stale data (the most dangerous, since the user sees possibly wrong data and does not know it); and **flagging desync while awaiting a fresh snapshot** (the right answer). Keep the old data visible but add a clear notice, because showing possibly wrong data is worse than showing 'syncing'.
- Expect the follow-up on whether `replace` to a missing key should be leniently treated as `add`: no. The server sending replace means it believes the key exists and you do not have it, which is itself the divergence signal. Quietly patching over it hides the problem until it surfaces somewhere harder to debug.
答题要点
- 打不上是可预期事件而非 bug,含义是两端状态已经不一致。
- applyPatch 失败要返回结果而不是抛异常,别把同步问题升级成界面崩溃。
- 必须全有或全无:在副本上执行,失败整组丢弃返回原状态,不留半应用数据。
- 恢复策略是标记不同步并等服务端补发快照,界面保留旧数据但加明确提示。
- 静默忽略是最危险的选项,因为用户看到可能错的数据却不知道它是错的。
Key points
- A failed patch is an expected event, not a bug; it means the two sides have diverged.
- applyPatch should return a failure result rather than throw, so a sync issue does not become a crash.
- All or nothing: apply to a clone, discard the whole batch on failure, never leave half-applied data.
- Recover by flagging desync and awaiting a snapshot, keeping old data visible with a clear notice.
- Silent ignoring is the most dangerous option, since users see possibly wrong data unknowingly.
什么样的信息该放进共享状态面板,什么样的该留在对话里?What belongs in a shared state panel versus in the conversation itself?
国内高频海外高频进阶#information-architecture#ux#agent-ui分析过程 · 先想清楚再作答
- 这题看着像产品问题,其实有一条很技术的判据,答出来就说明你理解了两者的本质差别。
- 判据一句话:**聊天是追加的,状态是覆盖的**。会原地更新的信息放状态面板,只发生一次的信息放对话。
- 套上去很好用:一个走到第三步的流程、一份逐步补全的指标、一个正在被填写的表单,都会原地更新,所以属于状态面板。如果做成聊天消息,你会得到十几条「已完成第 1 步」「已完成第 2 步」的刷屏,用户还得自己在脑子里拼出当前状态。
- 反过来,模型的解释、提问、最终结论都是一次性的表达,属于对话。硬塞进状态面板会丢掉时间顺序,而对话的价值恰恰在于它记录了「什么时候说了什么」。
- 有个中间地带值得主动提:工具调用。它既有过程(参数准备、执行中)又有结果,本课的做法是把它作为一张卡片放在**对话时间线**上,因为它是「某个时刻发起的一次动作」,时间位置有意义;而它的状态变化发生在卡片内部,不影响时间线。这说明第三种形态是存在的——时间线上的可变卡片。
- 可预期的追问是「那状态面板要不要显示历史」——通常不要。面板的价值就是「当前是什么样」,需要历史时应该是一个独立的时间线视图或者版本对比,不要把两种心智模型混在一块屏幕里。
How to reason about it · think before answering
- It looks like a product question but rests on a sharply technical criterion, and stating it shows you understand the underlying difference.
- The rule in one line: **chat appends, state overwrites**. Information that updates in place belongs in the state panel; information that happens once belongs in the conversation.
- Apply it and it works cleanly: a workflow on step three, a metrics set filling in, a form being completed — all update in place, so they belong in the panel. As chat messages they become a dozen 'step 1 done' and 'step 2 done' posts, leaving the user to reconstruct the current state mentally.
- Conversely, the model's explanations, questions, and final conclusions are one-time utterances and belong in the conversation. Forcing them into a panel destroys temporal order, which is precisely what the transcript is for.
- There is a middle ground worth raising: tool calls. They have both process and result, and this course places them as cards on the **conversation timeline**, because a call is an action initiated at a moment and its position in time is meaningful, while its state changes happen inside the card. So a third form exists: mutable cards on a timeline.
- Expect the follow-up on whether the state panel should show history: usually not. Its value is 'what things are now'. When history matters, build a separate timeline or diff view rather than mixing two mental models on one screen.
答题要点
- 判据是聊天追加、状态覆盖:会原地更新的进状态面板,只发生一次的进对话。
- 流程步骤、逐步补全的指标、被填写的表单属于状态面板,做成消息会刷屏。
- 模型的解释、提问、结论是一次性表达,属于对话,塞进面板会丢掉时间顺序。
- 工具调用是中间形态:作为可变卡片放在对话时间线上,因为发起时刻有意义。
- 状态面板通常不显示历史,需要历史应另做时间线或版本对比视图。
Key points
- The rule: chat appends, state overwrites. In-place updates go to the panel; one-time events go to the conversation.
- Workflow steps, accumulating metrics, and forms belong in the panel; as messages they spam the transcript.
- Explanations, questions, and conclusions are one-time utterances and belong in the conversation.
- Tool calls are the middle form: mutable cards on the timeline, since when they were initiated matters.
- Panels generally should not show history; build a separate timeline or diff view when history matters.