接上篇。下半场换了个人,风格明显不同:他不问架构,只挑实现细节和我没做的部分。项目背景仍然是我自己写的 Etyon 桌面 Agent 工作台。
开场:先得罪题面
面试官
Agent 生成长答案时,用户要暂停、要继续、要重新生成。这三个你都是怎么实现的?
我先得罪一下题面:对 LLM 推理来说,“暂停”是个伪概念。推理 API 没有暂停 / 恢复一说,你真正能做的只有两件事——停止(abort 这次请求),和继续(带着历史重新发一次请求)。把这句话讲清楚,这道题就答了一半;剩下一半是工程:停得干净、续得上、重得对。
面试官
用户不管你 API 有没有。他就是想要暂停。你要跟产品说“这做不了”?
我不会说做不了,我会说:能做的是两件不同的事,得让用户知道自己按下去的是哪一件。所以按钮文案我写“停止”不写“暂停”——按钮承诺的语义必须是真的。真想要“暂停”的那种体验,唯一诚实的实现是:停止 + 把上下文原样留在对话里 + 一键继续(在 Etyon 里就是往输入队列里排一句“继续”)。假装能暂停,代价是用户以为模型正在原地等着,其实那一轮已经结束了——等他回来发现进度没动,那才是真正的信任损失。
停止:一条要打穿三层的 abort 链
面试官
那说停止。你怎么保证服务端真的停了,而不是界面停了、后台还在烧 token?
停止在 UI 上只是调一下 useChat 的 stop(),但它背后是一条必须完整的链:前端 abort 掉 fetch → Hono 服务端从 c.req.raw.signal 读到取消信号 → Agent 循环在每一轮的开头检查它。多轮工具调用的 Agent 尤其如此——abort 到达时模型可能正跑在第三轮循环里,必须由循环自己决定在哪些安全点退出。
// 每一轮的第一件事:看有没有被叫停
while (true) {
if (abortSignal?.aborted) {
return buildOutcome("aborted")
}
try {
const result = streamText({ messages: history, model /* ... */ })
// ...消费这一轮的输出
} catch (error) {
if (abortSignal?.aborted) {
return buildOutcome("aborted") // 流中途被掐断,也归并到同一个出口
}
// ...
}
}注意 catch 里那一段:流在中途被掐断会以异常的形式冒出来,如果不在这儿再判一次 abort,它就会被当成一个普通错误报给用户。两个入口归并到同一个出口,是这段代码里唯一不那么显然的地方。
面试官
被停掉的那半段内容,你是直接扔了吗?
没有。SSE 流被 tee 出一份,客户端断开后服务端仍会把源头流消费到循环的 abort 出口,于是流的 onEnd 回调依然拿得到那条写了一半的助手消息,盖上 exitReason: 'aborted' 落库。用户视角:点了停止,已经生成的半段分析还留在对话里,刷新也在。停止的语义是“到此为止”,不是“当无事发生”。
面试官
停止按钮放在哪?
发送和停止是同一颗键。流式期间输入框为空,它是停止;用户已经在打后续问题,它保持发送(消息进排队)。按下停止时,排队中的后续消息一并清空——用户按停止的意图是“都别动了”,不该停完又把队列自动发出去。
// 同一颗按钮:流式中且没在打字 → 停止;打了字 → 发送(排队)
const isStopAction = isOutputActive && !hasInput
const actionIcon = isStopAction ? StopIcon : SentIcon
// 停止 = 停流 + 清空排队的后续消息
const handleStop = useCallback(() => {
setQueuedMessages([])
clearTodos()
void stop()
}, [stop])“继续”的三种真实形态
面试官
“继续”呢?你说它其实是重新请求,那用户看到的“继续”有几种?
- 排队继续:Agent 还在跑时用户继续输入,消息进渲染进程里的一个队列(可拖动排序、可删除),本轮进入稳定态后每次放行一条——用户不用等,Agent 也不会被并发请求打乱;
- 审批后继续:需要审批的工具会把一轮挂起,用户点完同意或拒绝,
sendAutomaticallyWhen的条件满足,整份消息历史自动重新 POST,下一条流接着跑——“继续”就是重新请求,状态全在消息历史里; - 崩溃后的收尾:进程崩了,下次启动时把卡在
running的 run 收成failed(标记 interrupted),等审批而挂起的 run 原样保留——界面上不会出现永远转圈的“僵尸回答”。
面试官
第一种,排队消息存在哪?
渲染进程内存里,不落库,重启即失。
面试官
重启即失,那就是丢用户数据。
是。我不狡辩这一点,但我要把它的量级说清楚:排队消息的生命周期通常以秒计——用户排它,是因为 Agent 正在跑、他不想等;而进程崩溃是以周计的事件。这个交集小到至今没咬过我。补起来也很便宜,写进会话的本地存储就行。如果哪天崩溃率上去了,这是我第一个补的地方——但今天它是个我知道自己在付的取舍,不是个我没想到的 bug。
另外两条诚实边界,主动说比被问出来强:Etyon 没有“续写被截断回答”的按钮——要续,自然的做法就是在队列里排一句“继续”;没有 SSE 断线重连(useChat 没开 resume),恢复的单位是轮次不是连接,上半场已经交代过为什么。
重新生成:一个锚点,两种入口
面试官
重新生成呢?历史怎么截断,你自己写的?
不自己写。我完全托付给 useChat 的 regenerate({ messageId }):把历史截断到目标消息之前、重新请求,裁剪逻辑是 SDK 的事。我只负责两件事——一是把当前的请求上下文(模型、agentMode、permissionMode、mentions)重新构造好一起带上,因为重新生成要在此刻选定的模式和模型下跑,而不是复刻当年那一次;二是入口:每条助手消息的操作条(复制 / 点赞点踩 / 重新生成),加上出错时错误条上的重试(不带 messageId,直接重做最新一轮)。
const handleRegenerate = useCallback((messageId?: string) => {
clearError()
setEditingMessageId(null)
void regenerate({
messageId, // 不传则重新生成最新的助手回复
...buildChatRequestOptions(getMessageRegenerateMentions(messageId))
})
}, [clearError, regenerate])面试官
编辑重发是另一套逻辑吗?
同一个原语。用户消息带编辑入口,提交时拿原来的 message.id 调 sendMessage——SDK 替换那个用户轮、丢弃其后的所有消息、重新请求。“同一个问题再答一遍”和“改掉问题重新问”,本质上都是:以某条消息为锚,截断历史,重新请求。认出这一点,两个功能就只用维护一条代码路径。
面试官
被丢弃的那个版本呢?ChatGPT 那种左右切版本,你没做。
真丢了,我没做。要做的话,改动不在 UI 而在存储:消息得从线性列表升级成“树 + 当前路径”,落库要存父指针,重新生成变成开一条新分支,UI 再加版本切换器和“这条消息有 3 个版本”的指示。这个成本对一个单人用的桌面工具暂时不值得——我用它的时候,重新生成的意思就是“刚才那次不算”。但方案我能当场画出来,这类“想清楚了、没做”的边界恰恰是面试里最好讲的部分。
工具状态卡:状态机不自己发明
面试官
一次回答里可能有几十次工具调用。那个状态指示器,你的 UI 状态是怎么定义的?
第一条原则:指示器的状态不该由 UI 自己攒布尔值(isLoading、hasError……),而应该直接从流协议的工具状态派生。AI SDK v7 的 tool part 自带生命周期:input-streaming(参数还在流入)→ input-available(参数齐了,执行中)→ output-available(有结果)或 output-error(失败)。接上人类审批之后,Etyon 实际要处理的是七个状态——多出 approval-requested(等人批)、approval-responded(批完、继续执行)和 output-denied(被拒绝)。UI 对它们唯一的加工,是一层翻译函数。
// 协议状态 → 展示状态的唯一翻译层
export const mapAssistantToolPartStateToChatToolState = (
state: string
): ToolPartState => {
switch (state) {
case "approval-requested": {
return "requires-action"
}
case "input-available":
case "input-streaming":
case "output-available":
case "output-error": {
return state
}
case "approval-responded": {
return "output-available" // 批完了:继续按“执行中/有结果”展示
}
case "output-denied": {
return "output-error" // 被拒绝:按“终止”展示
}
default: {
return "input-available"
}
}
}面试官
这一层翻译函数是不是多余的中间层?直接用协议状态渲染不就行了。
它是协议适配器,作用是把变化关在一个地方。流协议将来加状态,只改这一处;approval-responded 归并进“执行中”、output-denied 归并进“终止”这类降级决定,也集中在这里,而不是散落在十几个渲染分支里。少了它,协议每加一个状态,我要去每个 switch 里补一个 case,而漏掉的那个会以“白屏”或“状态卡在转圈”的形式在生产里告诉我。
面试官
那颜色呢?七个状态压成几种?
四种。绿是成功(output-available);红是终止(output-error 和 output-denied 共用);琥珀是等人(approval-requested);灰是其余一切进行中。
// 七个协议状态,四种语义色
export const getToolTraceStateClassName = (state: ChatToolState): string => {
switch (state) {
case "output-available": {
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-300"
}
case "output-denied":
case "output-error": {
return "bg-destructive/10 text-destructive"
}
case "approval-requested": {
return "bg-amber-500/10 text-amber-700 dark:text-amber-300"
}
default: {
return "bg-muted text-muted-foreground"
}
}
}面试官
七压四,信息不就丢了吗?失败和被用户拒绝,明明是两回事。
对系统是两回事,对用户是同一类:“这事没成”。颜色只负责回答这一层——成没成、要不要我动手——四种就够;剩下的区分交给文案、图标和展开后的详情,那里明确写着是报错还是你拒绝了。给七个状态配七种颜色,用户得先学一张色表才能看懂界面,那是把认知成本转嫁给用户。颜色是分类信号,不是数据字段。
动效:为什么全屏没有一个 spinner
面试官
进行中的动效呢?
这里有个可能反直觉的决定:整个聊天界面里没有一个旋转 spinner。我用两种更安静的动效表达“进行中”:文字上的 shimmer(微光扫过工具名或活动描述),和小圆点的 pulse(终端的流式光标 ▋、待办清单里进行中的点、子 Agent 的状态点)。
面试官
没有 spinner,用户凭什么知道它在动?
因为动效贴在信息本身上——用户看到的是“正在读哪个文件”这行字在发光,而不是旁边一个不携带任何信息的圈。而且要算总量:一轮 Agent 回答可能带几十次工具调用,几十个 spinner 同屏就是噪音,它们还全都在说同一句废话。shimmer 的信息密度更高:它既说明“在动”,又指着“在动的是这件事”。
结构上,卡片是一个 Disclosure:默认收起,只露一行——左边按工具类别取图标(终端类、文件类、搜索类、workflow 类,兜底一个通用图标),中间是工具名和一段预览(命令或路径),右边状态徽章。唯一默认展开的是等待审批的卡片:需要人做决定的东西,不允许藏在折叠里。
参数还在流入的时候,卡片显示什么
面试官
工具参数是模型逐 token 生成的。参数还没齐的时候,你的卡片显示什么?半截 JSON?
我不写任何“半截 JSON”解析——AI SDK 会把增量组装成当前已知的 input 对象,卡片拿当前值渲染就行。input-streaming 是 Agent UI 特有的状态,硬要自己解析流到一半的 JSON,是给自己找一个永远补不完的边界情况清单。
配套的是三个截断常量:头部预览 160 字符、摘要 220 字符、展开详情 2400 字符。工具的输入输出可以是几 MB 的文件内容,不 clamp,一张卡片就能把渲染撑爆。截断还必须可见:命令输出被截断时挂一个 truncated 徽章——用户不该把片段误当全文。
命令类工具(bash、检查命令等)有自己的分支:一块仿终端,ANSI 转义翻成带色的 React 元素,流式期间显示 $ command 加一个 pulse 光标,结束后是 stdout / stderr、退出码和耗时。文件类工具(read / edit / write)则在卡片上给“到项目面板看 diff、看文件”的跳转按钮——大 diff 不内联塞进对话流,那会同时毁掉性能和可读性;edit / write 还额外带一个恢复检查点的入口。
面试官
审批呢,弹个 modal?
审批不是弹窗,是指示器的一等状态。主 Agent 的审批就是卡片的 approval-requested 态:自动展开,三个按钮——同意;“同意并记住”(把这条命令归纳成一个模式,下次自动放行;被判定危险的命令不提供这个按钮);拒绝。拒绝后工具进入 output-denied 的红色终态——拒绝也是一种结果,要留在时间线上,而不是卡片直接消失。
子 Agent 的审批卡由 data-subagent-approval 这个自定义 data part 驱动,多带一个 dangerous 标记:危险操作红色容器加警示文案,普通操作琥珀色;应答走单独的 oRPC 而不是 chat 流(上半场讲过这条分界线)。两条链路实现完全不同,但视觉语言严格一致:琥珀等于“等你决定”,红等于“危险或终止”。运行中的子 Agent 行同样遵守这套动效语言:shimmer 滚动显示当前活动(“正在 grep ……”),pulse 圆点表状态,外加一个实时计时器。
“可复用”三个字
面试官
你一直说这张卡“可复用”。给我一个能验收的边界——可复用到什么程度?
一张通用卡片加三层定制出口,而不是每个工具一张卡。Etyon 里九成工具走同一张 StructuredToolTraceCard,定制出口从轻到重是:
- 最轻:图标与文案——按工具名归类取图标,按状态取 i18n 标签;
- 中间:分支渲染——命令类换仿终端,文件类加跳转与恢复按钮;
- 最重:整个拉出去——
todo_write渲染成待办清单、delegate/ workflow 渲染成子 Agent 行、ask_user/propose_plan渲染成提问卡和计划卡。这些调用长得是工具,语义上不是,就不该长成工具卡。
还有一条配套的密度规则:连续的普通工具调用折叠成一组,组标签自动归纳(“跑了 N 条命令”“浏览了 N 个文件”)。指示器不能只设计“一次调用长什么样”,还得设计“三十次连着出现长什么样”——“可复用”包括批量出现时仍然可用。
面试官
没有 per-tool 渲染器注册表,你这也叫可复用?
目前确实没有正式的注册表,定制靠图标映射、类别 helper 和少量特判。我认为现在就上注册表是过度设计:九成工具走同一张卡,注册表会立刻变成一堆空实现,而“每个工具都可以有自己的渲染器”这句话,会诱使人给每个工具写一个。工具数量再涨一个量级,我会把它演进成注册表——每个工具可注册自己的预览、详情、动作渲染器,注册不了的落回通用卡。这个演进方向我会主动讲,而不是假装现在就有。
安全:这里你大概等着听“DOMPurify”
面试官
最后一块。前端如何安全地展示 Agent 返回的 HTML 或 Markdown,防住 XSS 又保留格式?
我想先退一步讲威胁模型,因为这道题在 Agent 场景里比传统 UGC 更严肃。常见的侥幸心理是“模型是我自己接的,输出可信”。错。Agent 的上下文里混着工具带回来的东西:抓取的网页、读到的文件、别人仓库的 README……间接 prompt injection 的第一站就是你的渲染器——攻击者把话埋在网页里,模型复述时带出恶意 Markdown,你的 UI 一渲染,XSS 就成立了。模型不是内容的作者,它只是搬运工,所以模型输出必须按用户生成内容对待,信任等级等同于陌生人的评论区留言。攻击面也比想象中宽:
[点我](javascript:...)——链接协议本身就是执行入口;<img onerror=...>、iframe、form——raw HTML 的标签与事件属性;——不执行任何代码,渲染即发请求,图片 URL 就是现成的外传信道;- Etyon 是 Electron 桌面应用——一次 XSS 的代价不是偷 cookie,而是可能摸到系统能力,这道题在我的场景里权重更高。
面试官
行,那你用的哪个消毒库?
一个都没用。我的主策略不是“渲染完再消毒”,而是不给 HTML 进 DOM 的机会。所有模型输出都从同一个组件出:AgentMarkdown。它在 streamdown 之上只加了三样东西。
// 所有模型输出唯一的 Markdown 出口
const ALLOWED_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"])
const ALLOWED_IMAGE_PROTOCOLS = new Set([
"blob:", "data:", "etyon-attachment:", "http:", "https:"
])
export const AgentMarkdown = ({ children, ...props }: AgentMarkdownProps) => (
<Streamdown
{...props}
mermaid={AGENT_MERMAID_OPTIONS} // securityLevel: "strict"
skipHtml // Markdown 里夹带的 raw HTML 整块丢弃
urlTransform={safeAgentUrlTransform} // 协议白名单,白名单外归零
>
{children}
</Streamdown>
)- AST → React 组件,没有 innerHTML:streamdown 底下是 react-markdown 一系,Markdown 先解析成语法树、再映射成 React 元素,文本永远走文本节点。整个仓库 grep 不到一个
dangerouslySetInnerHTML——没有注入点,是比任何消毒都强的“消毒”; - skipHtml:raw HTML 节点直接丢弃。格式上的损失可以接受——模型要表达的富文本(标题、列表、表格、代码、流程图)Markdown 本身全够;streamdown 内部还带 rehype-sanitize 一层兜底,但我不把安全押在下游依赖的默认行为上;
- urlTransform 协议白名单:链接只放行 http / https / mailto,图片多放行 blob / data 和应用自己的附件协议,白名单外(
javascript:、vbscript:、file:……)一律替换成空字符串。
面试官
不用 DOMPurify?我觉得你是在偷懒,或者是没想到。
DOMPurify 的用武之地是“我手里有一段必须原样渲染的 HTML”。这条默认路径里根本不产生这样的 HTML:不进 innerHTML、raw HTML 被丢弃——没有需要消毒的水,就不装净水器。而且装了还有反效果:它会让后来的人以为“这条路径已经消过毒了”,从而放心地去用 innerHTML。依赖树里唯一的 DOMPurify 来自 mermaid(画图需要),而 mermaid 被我锁在 securityLevel: 'strict'。
顺带说流式,这两者的交叉是个隐藏考点:“AST + 丢弃”的方案对半截 HTML 天然稳健——未闭合的标签要么还是文本、要么被丢;而“先渲染后消毒”的方案在流式下每一帧都要重新 sanitize,还得回答“消毒到一半的 DOM 算什么”这个问题。
面试官
白名单是不是太严了?用户就想点一个 file:// 链接呢?
方向不能反:这是白名单,不是黑名单——坏协议是枚举不完的,今天你封了 javascript:,明天有人拿 data:text/html 或者某个平台自有 scheme 绕过去。至于严不严,看降级方向的不对称:安全失败丢的是格式(可接受),格式失败丢的是安全(不可接受)。skipHtml 就是按这个不对称选的。真有合法的 file:// 需求,正确的做法是给它一条显式的、走 IPC 的 UI 动作,而不是把整个协议放回白名单。
面试官
可你上半场说 artifact 能让模型产出完整 HTML 页面。那不是自相矛盾?
那里换了一个安全模型。artifact 允许模型产出完整 HTML(小工具、可视化 demo),这种内容“保留格式”意味着必须让脚本执行——所以不是消毒,是隔离:srcdoc 注入一个 sandbox 只给 allow-scripts 的 iframe,刻意不给 allow-same-origin;脚本随便跑,但它跑在一个 opaque origin 里,摸不到宿主的 DOM、存储和 IPC 桥;iframe 里再注入一条独立 CSP,把外呼也掐死。
// 任意 HTML 的安全模型:不是消毒,是隔离
export const ARTIFACT_IFRAME_SANDBOX = "allow-scripts" // 刻意不给 allow-same-origin
// srcdoc 里注入的独立 CSP:能画,不能外呼
// default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';
// img-src data: blob:; font-src data:; media-src data: blob:;
// connect-src 'none'; object-src 'none'; form-action 'none'; base-uri 'none'面试官
如果这两层都漏了呢?
纵深防御的意义就是“上一层失手时,下一层还站着”。Etyon 的宿主是 Electron,做了三件事:
- 窗口硬化:contextIsolation 开、nodeIntegration 关、sandbox 开——就算渲染进程真被 XSS,也拿不到 Node 的能力;
- 导航封锁:
window.open一律 deny,主 frame 任何离开应用 origin 的跳转都被拦截;真正放行的外链只有一条路——显式 UI 动作(比如引用来源的 chip)走 IPC,主进程校验过 http(s) 前缀后交给系统浏览器打开; - 渲染进程严格 CSP:
default-src 'none'起手、script-src 'self'、img-src 白名单里没有远程 origin——前面那张就算穿过了 Markdown 层,请求也根本发不出去。
面试官
同一套 UI 部署到 Web 上,你这三层还剩几层?
这是我认为这道题最好的追问方向。逐层算:
- Markdown 那层原样成立——它和宿主无关,AST 渲染、skipHtml、协议白名单换到浏览器里一模一样;
- 沙箱 iframe 成立但要补:srcdoc 内联的 CSP 还在,但得额外从服务端下发响应头 CSP,不能只靠内联那一条;
- 第三层整层没了:没有 Electron 的
window.opendeny,a标签上的rel="noreferrer"(现代浏览器里蕴含 noopener 的效果)就从“顺手加的”变成“必须的”;CSP 要从服务端下发;还多出一整类本地应用里根本不存在的问题——cookie 与会话怎么防偷。
所以安全结论从来不是“这套方案安全”,而是“这套方案在这个宿主下安全”。换宿主要重算一遍,这件事本身比任何一条具体措施都值得记住。
最后补一类容易被漏掉的输入:工具输出。文件内容、命令 stdout 同样是不可信文本,但它们不走 Markdown——通用工具输出直接 pre 纯文本渲染,终端输出用 ansi-to-react 把 ANSI 转义翻成 React 元素(不是 innerHTML),再加前面说的 220 / 2400 字符截断。原则一句话:默认纯文本,富渲染是白名单特权,不是所有内容的默认待遇。
收尾
面试官
最后一个问题,说点真话:这套东西你自己觉得最大的风险在哪?
三个,按我自己担心的顺序:
- 审批疲劳。安全这块最脆的不是渲染层,是工具的权限模型:一旦用户被弹烦了,“同意并记住”就会变成一张长期通行证,而它当初是按“这条命令归纳成一个模式”来设计的,模式写宽一点,就等于把审批整个关掉。这类风险不是代码 bug,是交互设计和人性的交界处,最难测。
- “想清楚了没做”的东西会同时到期。排队消息不落库、没有多版本分支、没有虚拟化、没有 per-tool 注册表——今天它们都成立,因为使用量小;用户量或会话长度上一个量级,它们会在同一周里一起变成问题。
- 我的很多数字是推出来的,不是测出来的。50ms、48px、160 / 220 / 2400,都有推理和边界,但没有实测支撑。单人项目里这够用;如果这是一个团队产品,我应该拿数据说话,而不是拿论证说话。
这场面试的复盘
两个半场问下来,我的体会是:压力面试真正在考的从来不是“你会不会用某个库”,而是四件事——
- 每一个“为什么”背后有没有取舍:说得出你放弃了什么,才算做过决定;只说好处的方案,通常是抄来的。
- 你没做的部分,是没想到还是想清楚了不做:这两者在简历上长得一样,在追问下三句话就分开了。
- 换个宿主、换个量级,你的结论还成不成立:本地单用户 vs 云端多用户,几十条消息 vs 几千条——大半答案会翻面,能主动指出翻面条件的人很少。
- 你说的数字,是测出来的还是推出来的:两者都可以接受,混为一谈不行。
下半场如果只留一句:“暂停”对 LLM 是伪概念,能做的只有停止(一条从前端 fetch 打穿到 Agent 循环、且半成品必须带 exitReason 落库的 abort 链)和继续(排队、审批后自动重发、崩溃后启动收尾三种形态);工具状态卡的状态机直接从流协议派生、七态压成四色、用 shimmer 和 pulse 而不是 spinner;安全则先立威胁模型——模型输出按不可信 UGC 对待,默认路径用“AST + skipHtml + 协议白名单”从根上不产生注入点,必须执行的 HTML 关进无同源权限的沙箱 iframe,最外层交给宿主硬化和严格 CSP 兜底,消毒库只是这套分层里的可选件,不是答案本身。