这不是一份问答集,是一场压力面试的实录。
题目是我准备 AI Agent 岗位时攒下来的,答案全部出自我自己做的 Etyon——一个跑在本地的桌面 Agent 工作台。面试官是我扮演的:他不关心你会背什么,只关心你说的每一句话背后有没有取舍、有没有证据、有没有边界;你答得越顺,他越要往软处按一下。我把这些题重排成一条不断线的追问链,因为真实面试里没人会让你按题号答——你一句话说完,下一句永远是“那……呢?”
上半场四十分钟:项目、协议、流式渲染、对话组件。下半场在下篇:停止与重新生成、工具状态卡、安全。
开场:你到底做了个什么东西
面试官
先花两分钟,介绍一个你参与过的、涉及 AI Agent 的前端项目。别念简历,讲你自己写的那部分。
我讲 Etyon。它是一个跑在本地的桌面 AI Agent 工作台,Electron 打包,界面 React 19。可以把它理解成一个“能自己动手改代码、跑命令”的 Agent:你给它一句话,它自己规划、调用工具(读文件、改文件、执行命令……)、必要时停下来等你批准,然后继续,直到把活干完。前端、本地服务端、Agent 循环、还有一个 Rust 写的命令行客户端,都是我写的。
面试官
所以就是个套壳聊天框。Agent 前端和普通前端到底有什么区别——我不想听见“流式”两个字就完事。
区别在心智模型。CRUD 前端是“发一个请求、拿一个响应、渲染出来”,请求和响应之间是一条直线。Agent 前端要处理的是一个会自己往前走的过程,它有五个性质,每一个都在打破那条直线:
- 流式:模型输出是一个 token 一个 token 吐出来的,一次对话可能几十秒,UI 得边收边渲染——所以“响应”不是一个值,是一段时间。
- 多轮工具调用:一次“回答”里 Agent 可能来回调好几次工具,每次调用和每个结果都要实时显示在对话里——所以一次请求里装着 N 次子过程。
- 人类在环:有副作用的操作要停下来等用户批准,一次“请求”会在中途被人为打断、再继续——所以请求的生命周期里嵌着一个人的决策时间。
- 可中断、可恢复:用户随时能点停止,进程也可能崩溃,半路断掉的那次回答不该凭空消失——所以“失败”不是回滚,是留痕。
- 非确定性:同样的输入,两次跑出来的工具序列可能不一样,UI 不能假设一条固定流程——所以你不能画死状态图,只能设计一个能接住任意序列的渲染器。
这五条加起来,决定了这个项目里最值得想清楚的不是组件树,是前端和 Agent 服务端之间那份协议。
边界:引擎凭什么不放在前端里
面试官
你说前端只是个客户端。那 Agent 循环为什么不直接跑在渲染进程里?多一跳 HTTP、多一层麻烦,图什么?
因为我要的就是那条边界。Electron 的主进程里跑着一个 Hono 服务,绑在 127.0.0.1 上、用系统随机分配的端口(port: 0);React 渲染进程只是这个服务的一个 HTTP 客户端。这一跳确实存在,但它在同一台机器的回环地址上,延迟可以忽略;换来的是前端代码里永远不会 import Agent 的循环逻辑——它甚至不需要知道背后是哪家模型,它只知道怎么发 HTTP、怎么读流。
面试官
“边界清晰”是个形容词。给我证据。
证据是我还写了一个 Rust 命令行客户端,它打的是同一个 /api/chat 接口、用同一套鉴权。同一个引擎,桌面 UI 和 CLI 都只是它的瘦客户端。如果边界没画干净,CLI 这件事根本做不成——我得把 Agent 循环再实现一遍。
// 同一个引擎的另一个瘦客户端:CLI 打的也是 /api/chat
pub async fn chat(&self, input: JsonValue) -> ClientResult<reqwest::Response> {
let response = self
.protected_request(Method::POST, "api/chat") // 带上同一套 bearer token
.json(&input)
.send()
.await?;
if response.status() == StatusCode::UNAUTHORIZED {
return Err(ClientError::Unauthorized);
}
Ok(response.error_for_status()?) // 原样返回流式响应
}
// 消费端:按 SSE 逐帧读取
let mut stream = response.bytes_stream().eventsource();
while let Some(event) = stream.next().await { /* 逐帧输出 event.data */ }面试官
在本机开一个能改你文件、能跑命令的 HTTP 服务,等于给这台机器上所有程序开了个后门。这你怎么交代?
四道锁:只绑 127.0.0.1,不对外;端口是 port: 0 由系统随机分配,没法预测;/api/* 全部走 bearer 鉴权,渲染进程启动时先通过一个本地 RPC(server.getUrl)拿到 http://127.0.0.1:<port> 和 token,再拿这两样去连;CORS 锁死在 localhost。端口随机这一条顺带解决了另一个问题——地址不能写死在前端里,所以“先要地址再连”本来就是必经流程,安全和工程在这儿是同一个决定。
协议:你发明了什么,又没发明什么
面试官
前端和服务端之间那份协议,你自己定的?
没有,而且我认为自己发明协议在这道题里是减分项。我站在 AI SDK v7 上:前端用 useChat(来自 @ai-sdk/react)配一个 DefaultChatTransport,指向 ${url}/api/chat,带上 Authorization: Bearer <token>;服务端返回的是一条 SSE 流(text/event-stream),说的是 AI SDK v7 的 UI Message Stream 协议。
// 端口是运行时随机的:先向主进程要地址和 token,再构造 transport
const { token, url } = await rpcClient.server.getUrl()
transport = new DefaultChatTransport<UIMessage>({
api: `${url}/api/chat`,
headers: { authorization: `Bearer ${token}` }
})面试官
那你做了什么?把 SDK 的示例代码抄进项目里吗?
示例代码解决不了我的问题。最省事的写法是 result.toUIMessageStreamResponse()——一次调用、一条流、结束。但我的一次“回答”里装着一整个 Agent 循环:模型说话、调工具、看结果、再说话,可能来回好几轮。所以我用的是更底层的 createUIMessageStream + createUIMessageStreamResponse,目的是让一次 HTTP 响应装下多轮模型往返,外加我自己塞进去的自定义数据。
// 一条 SSE 响应,装下整个 Agent 循环的多轮往返
return createUIMessageStreamResponse({
consumeSseStream: consumeStream, // tee 一份:客户端断开也把流消费到底
stream: createUIMessageStream({
execute: async ({ writer }) => {
// Agent 循环:每轮一次 streamText,把输出 merge 进同一个 writer
},
onError: describeChatStreamError,
onEnd: async ({ isAborted, messages: nextMessages }) => {
// 即使被中断,也能拿到写了一半的助手消息 → 落库
},
originalMessages: messages
})
})循环里每一轮就是一次 streamText,把它的输出 merge 进同一个 writer。于是文字增量、推理过程、每一次工具调用、每一个工具结果,都作为带类型的 part 顺着这一条流回到前端;useChat 把这些 part 重新拼装成消息对象,UI 只管渲染拼好的结果。前端完全不需要知道 Agent 内部循环了几轮——它看到的就是一条不断长出新 part 的消息流。顺着这条流回来的大致三类:
- 文字增量(
text-delta)与推理片段(reasoning); - 工具调用与工具结果(AI SDK v7 原生的 tool part),UI 直接内联渲染“调了哪个工具、参数是什么、返回了什么”;
- 我自定义的 data part——请求阶段、运行额度、子 Agent 进度、待办清单等等,前端用
useChat的onData回调接住。
面试官
为什么是 SSE?WebSocket 不是更自然吗——双向、还能重连。
因为我的数据流其实是单向的:服务端往前端推,用户的输入是离散事件,走普通 POST 就够了。为了这点单向数据去开一条双向连接,我要自己补心跳、重连、消息编号、连接生命周期管理,而这些没有一件是 SSE 用户需要操心的。更关键的是 abort 语义:SSE 就是一个普通 HTTP 响应,前端 fetch 的 AbortSignal 一路传到 Hono 的 c.req.raw.signal,取消是天然的;鉴权、CORS、代理也全部复用 HTTP 那一套。
什么时候 WebSocket 才开始划算?当你需要服务端主动推送与某次请求无关的事件(多端同看一条会话、别人的操作实时同步过来),或者需要真正的双向低延迟。Etyon 是单机单用户,这两条都不成立。
每次都发整份消息历史
面试官
你的请求体是 { messages, sessionId, model?, agentMode?, permissionMode?, mentions? }。每次都把整份历史发过去?不觉得浪费?
发的是整份 UIMessage[],不是“这次新说的那一句”。先说浪费在哪、不在哪:链路是本机回环,带宽不是瓶颈;真正的成本是 token,但那份历史无论如何都要进模型上下文——服务端自己从库里拼,和前端发过来,进模型的量是一样的。换来的东西是:服务端不用为每个会话维护一台状态机。
这一点在后面两处会兑现:审批的结果、被中断那一轮的残留,都以 part 的形式待在消息历史里,下一次请求带着它们过去,服务端读一眼就知道该从哪继续。所谓“状态”,我尽量不让它活在服务端内存里,而是活在每次都完整发送的消息历史里。
面试官
那如果哪天要上云、要服务几千个人呢?
那我会翻过来:前端发增量,服务端持有权威历史。代价是服务端要多一套会话状态与并发一致性问题——而这套问题在单机单用户下是白付的。所以这不是一个普适真理,是一个被环境约束的决定。面试里我更愿意把它讲成“在这个约束下我选了简单的那个”,而不是“整份发才是对的”。
审批:同一件事,为什么落成两套
面试官
有副作用的工具要人批准。你怎么做的?
写文件、改文件、执行命令这些工具都声明了 AI SDK v7 原生的 needsApproval。主 Agent 撞上需要审批的工具时,循环不是阻塞一个线程在那儿干等,而是干脆地“挂起”(suspend)——这一轮的 SSE 流正常结束,前端收到一张审批卡片。用户点同意或拒绝之后,这个决定被 addToolApprovalResponse / addToolResult 追加成消息里的一个 part;接着 useChat 在“该自动发送了”的条件满足时,把整份消息列表重新 POST 回 /api/chat。服务端从历史里读到这个批准,就在下一条流的开头执行(或跳过)那次工具调用,再接着往下跑。
面试官
等一下。用户在那儿犹豫的时候,服务端那条流已经结束了?那这一轮的执行上下文不就丢了吗?
丢了,而且是故意的。挂起意味着这一轮的状态全部沉淀进消息历史和数据库,不靠进程内存活着。好处很实在:审批状态天然是持久化的、扛得住重启,服务端也不用挂着一条长连接去等一个人类去泡杯咖啡。代价是下一次请求要把上下文重新喂一遍——而上一题已经交代了,这份上下文本来就得进模型。
面试官
你刚才说“主 Agent 的审批”。意思是还有别的?同一件事两种实现,这不叫设计,这叫没统一。
确实是两条路,而我认为分界线是清楚的。当主 Agent 把任务委派给一个子 Agent 时,子 Agent 不能用 needsApproval——那会把子循环整个拆掉。所以那里换了个玩法:审批请求作为一个瞬时的 data part(data-subagent-approval)流到 UI,用户的回答走另一个 oRPC 接口(agents.respondToApproval,跑在 /rpc/* 上)送回去,去解开一个内存里的 approval broker。这条路完全不经过 chat 的 SSE 流。
判断依据只有一句话:这件待批的事需不需要跨重启存活。 主对话的一轮可能很长、值得持久化;子 Agent 只活在这一轮之内,用内存 broker 更轻。硬要统一成一套,要么给子 Agent 强加一套它不需要的持久化,要么把主对话的审批降级成“进程活着才有效”——两种统一都是把成本换个地方付。
中断:这里你大概想听 resume token
面试官
用户点停止,从那一刻起发生了什么?
useChat 的 stop() abort 掉那次 fetch,服务端通过 c.req.raw.signal 拿到 abort 信号,Agent 循环每一轮开头都检查它、然后退出。这条链的三层缺一不可,具体的实现细节下半场会被问到。
面试官
那已经生成的那半段内容呢?
留着。这是一个容易做错的细节:如果只是简单地 abort,半路那段已经生成的内容就丢了。我的服务端会把这条 SSE 流 tee 一份出来(上面代码里的 consumeSseStream):就算客户端断开了,源头那条流和消息组装仍然会跑到循环的 abort 出口,于是流的 onEnd 回调依然能拿到那条写了一半的助手消息,给它盖上 exitReason: 'aborted' 再落库。“我点了停止”不等于“那半条回答凭空消失”。
面试官
那 SSE 断了呢——不是用户点的,是网络抖了、进程卡了。你的 resume token 怎么设计的?
没有 resume token,也没有断流重连。这套系统里所有的“恢复”,指的都是恢复一次被挂起的 Agent 轮次:重新发一个 /api/chat POST,把持久化的审批和消息历史揣在请求里带过去。可恢复性我做在了“轮次 / 消息”这一层,没做在“传输连接”这一层。
面试官
所以你就是没做。
我没做那一个,做了另一个,而且我认为在这个宿主下另一个更对。连接级恢复要服务端为每条流维护可寻址的缓冲区和游标,客户端带 last-event-id 回来续;这套东西在多用户云端服务里是必需的,因为一次生成几十秒、网络不可控。而 Etyon 的链路是同一台机器上两个进程之间的 127.0.0.1——这条链路断掉的概率,和“进程整个崩了”几乎是同一个事件,而进程崩了,连接级恢复也救不了你。所以我把预算花在了轮次级:崩溃重启后,卡在 running 的 run 被收成 failed,挂起等审批的 run 原样保留,用户重新发一次就能接上。
换个宿主,这个结论就要换。这套 UI 要是搬去 web 服务几千人,我会补上 SSE resume——那时候网络不可控、生成很贵,重放一次的成本高到值得为它维护缓冲区。
事件溯源那一层
面试官
你提到落库。UI 里的对话记录,是从事件日志重放出来的吗?
不是,我把两件事分开了:
- UI 渲染用的对话记录:一份“快”的读模型,存在
chat_messages里,启动时作为initialMessages喂给useChat; - 一份持久的事件日志:
agent_runs/agent_events/agent_tool_calls/agent_approvals等表,在每次运行时顺带写下来。
面试官
两份数据、写两次、还可能不一致。为什么不合成一份?
因为它们的读取模式根本不一样。对话 UI 在启动时和每次渲染时都要读,要求的是“一次查询拿到当前状态”;事件日志只在你点开 Run Inspector 的时候才按需投影(buildRunProjection),用来展示这一轮的运行头、每一次工具调用和事件时间线。合成一份,只有两个结局:要么让对话 UI 在热路径上付事件重放的成本,要么让审计流丢掉中间过程、退化成一份状态快照。
不一致的风险是真的,代价也是明确的——写路径必须在同一个地方同时更新两者。我接受这个代价。运行的生命周期也挂在这套日志上:流开始前先开一行 running 的 run,流进行中追加每一步,结束时结算成 succeeded / suspended / failed;下次启动时若发现还有卡在 running 的行(说明上次崩了),就把它们收成 failed;持久化的审批有 7 天 TTL。
面试官看了眼时间:“架构讲得差不多了。换个方向——聊点你每天都要盯着看的东西。”
流式渲染:两千字的分析为什么不卡
面试官
Agent 吐一段两千字的多段落分析,前端怎么渲染才不掉帧?
先把问题的形状说清楚:文字以 text-delta 一片一片到达,useChat 把它们拼成一个不断变长的消息对象。所以这件事的工程本质是——一个每几十毫秒就变长一次的 Markdown 字符串,要被反复渲染成富文本,并且不掉帧。我用了三层削减,从传输层削到应用层。
第一层:模型吐 token 的速度可以远超 60fps,如果每个增量都触发一次状态更新,React 一秒要跑几百次协调,其中大部分工作花在渲染人眼根本分辨不出来的中间态上。我在 useChat 上开了 experimental_throttle: 50,UI 状态每 50ms 才向 React flush 一次。
// 流式渲染的第一道节流阀:UI 状态每 50ms 才 flush 一次
} = useChat<ChatUiMessage>({
experimental_throttle: 50,
id: selectedSession.id,
messages: initialMessages,
onData: (dataPart) => { /* 请求阶段 / 子 Agent / 待办等自定义 data part */ },
sendAutomaticallyWhen: shouldSendChatAutomatically,
transport
})面试官
50 是怎么来的?拍脑袋拍的吧。
我不打算把它说成实测结论——我没有做过 16 / 33 / 50 / 100 的火焰图对比。我能给的是这个数的推理和它的两个边界:低于一帧(16.7ms)没有意义,因为屏幕根本刷不出来;高于 100ms 开始像“一段一段往外蹦”,流的质感就没了。50ms 是这两个边界之间偏保守的位置——比一帧粗三倍,文字出现的粒度人眼分辨不出,又足够细,阅读节奏仍然是流。如果你要我拿数据说话,我现在给不出来;这是一个单人项目里够用、放到团队项目里我应该补测的地方。
面试官
行,接着说第二层。
第二层在解析侧,也是流式 Markdown 最大的坑:朴素做法是每次更新都把整段 Markdown 重新 parse、整棵树重新渲染,段落越多越卡,整体是 O(n²)。我没有裸用 react-markdown,而是用 streamdown(Vercel 为流式场景做的 Markdown 渲染器):它先把 Markdown 切成块(parseMarkdownIntoBlocks),每块单独 memo——token 追加时,只有末尾那个还没定型的块会重新解析渲染,前面几十段已经完成的分析一次都不会动。
第三层在应用层,再叠一层 React.memo:消息列表里的每条 ChatMessageItem 都是 memo 的,流式期间真正重渲染的只有正在生长的那一条,历史消息在 props 不变时整条跳过。三层削下来,每次 flush 的渲染成本约等于“一条消息里正在变的那个块”,而不是整个对话。
面试官
流到一半的 **加粗 呢?没闭合的代码围栏呢?那才是流式渲染真正会出丑的地方。
是,这是流式渲染特有的隐藏考点:Markdown 在传输过程中永远处在语法未闭合的中间态。streamdown 把这一层处理掉了——半截的加粗、没写完的代码围栏不会闪成乱码,而是稳定地按“还没成型”的样子渲染。这也是我选它而不是自己拿 react-markdown 攒一个的主要原因:性能我自己也能优化,中间态的那些边角我不想再踩一遍。
还有个和性能相关的设计:逐字动画是个用户设置(fade-in / blur-in / slide-up / typewriter / 关掉),映射成 streamdown 的 AnimateOptions。typewriter 模式的字符瀑布必须塞得进 50ms 的节流窗口,不然动画队列会越欠越多;另一头,已经定稿的历史消息一律 animated={false}——动画成本只付给直播中的那一条。
// 只有直播中的那条消息付动画与光标的成本
const animated = getStreamdownAnimateOptions(streamdownAnimation)
const shouldAnimate = isAnimating && animated !== false
return (
<AgentMarkdown
animated={animated}
caret={shouldAnimate ? "block" : undefined} // 流式期间的块状光标
className={STREAMDOWN_MARKDOWN_CLASS_NAME}
components={STREAMDOWN_MARKDOWN_COMPONENTS}
isAnimating={shouldAnimate}
>
{text}
</AgentMarkdown>
)滚动条归谁
面试官
内容一直在长,滚动怎么办?
一句话:内容在长,视口动不动,取决于用户此刻站在哪。用户在底部就跟着滚;用户上滑离开了底部,立刻停手——把正在读上文的人硬拽回底部,是流式 UI 最败好感的行为。实现比想象中朴素,不需要专门的库,一个 ref 加一个阈值。
// 距底 48px 以内视为“在底部”:跟滚;否则让位给用户
const isNearBottomRef = useRef(true)
const handleMessagesScroll = useCallback((event) => {
const el = event.currentTarget
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
isNearBottomRef.current =
distanceFromBottom <= MESSAGE_SCROLL_BOTTOM_THRESHOLD_PX // 48
updateScrollToBottomVisibility(el)
}, [updateScrollToBottomVisibility])
useEffect(() => {
if (isNearBottomRef.current) {
scrollToBottom("instant") // 流式期间用 instant,smooth 会和高频更新打架
}
}, [messages])面试官
48 又是一个魔法数字。为什么不是 0——用户在底部就是在底部。
因为 0 判不稳。滚动事件的触发时机、字体度量、小数像素缩放,都会让 scrollTop 差一两个像素;用户明明贴着底部,却被判成“已经离开”,跟随就会时灵时不灵——而这种随机失效比不跟随更让人烦躁。48px 大约是一行多一点的容差,宁可把“在底部”判宽一点。这个数和 50ms 一样,是推出来的不是测出来的,但它的失败模式是良性的:判宽了,最多是用户刚上滑一点点还在跟随;判窄了,是跟随随机失效。
面试官
为什么用 instant 不用 smooth?smooth 明显更好看。
因为更新每 50ms 来一次,smooth 动画还没走完就被下一次打断,视觉上反而是果冻感——它在追一个一直在动的目标。高频小步的 instant 跟随看起来更稳。配套还有两条:“回到底部”按钮只在距底超过阈值时浮现,点击滚回并自动隐藏,用户永远有一条回去的路;以及“是否在底部”存在 useRef 而不是 state 里——它每次滚动都在变,放 state 会让滚动事件本身触发重渲染,那就和前面三道闸省下来的算力对冲了。
你没做虚拟化
面试官
消息列表虚拟化了吗?
没有,就是 messages.map 渲染在一个滚动容器里。
面试官
那我给你五千条消息,你这页面就死了。
会很慢,但我要先把两个问题分开:虚拟化解决的是 DOM 总量,我前面三层解决的是重渲染成本。桌面场景单会话的量级下,先到瓶颈的是后者,而后者已经被压到“一个块”了。而且 chat 恰恰是虚拟化最难啃的场景——消息高度动态、流式期间末尾一直在长、还得自己维护 scroll anchoring,做砸了的表现是滚动位置乱跳,比卡顿更劝退。
真到几千条的量级,我的第一步不是虚拟化,是“折叠久远历史、只完整渲染最近 N 条”:它便宜得多、不破坏滚动,而且符合真实使用——没人会滚回去逐字读三周前的第 47 轮。虚拟化留作最后手段。
面试官
听起来像在给“没做”找理由。
有一半是。我确实没做,因为没遇到;但“什么时候必须做、做之前先做什么”我想清楚了。面试里我更愿意把没做的说成没做——把没做的说成做了,你下一个问题就能把我问穿。
对话组件:装得下文本、代码、表格和图表
面试官
换个题。设计一个通用对话组件,要能装文本、代码块、表格、图表。你从哪开始?
从数据模型开始——对话组件的“通用”,九成是数据模型给的,组件本身只是最后一步。最重要的决定发生在渲染之前:一条消息在 UI 里是 UIMessage,role 加上一个 parts 数组,每个 part 自带类型。正文是 text part,推理是 reasoning part,工具调用是 tool-<name> part,我自己的扩展是 data-* part。所谓“混合内容”,不是“一个 Markdown 字符串里嵌各种东西”,而是类型化片段的序列,前端不做任何文本嗅探和格式猜测。
- 呈现型内容和状态型内容分家:代码块、表格是呈现问题,留在 Markdown 层解决;工具调用、审批、子 Agent 进度是带生命周期的状态,走类型化 part——不用把状态编码进文本、再用正则抠出来;
- 每种 part 有自己的流式中间态:text 在打字、reasoning 在思考、tool 在等结果,各渲染各的“未完成”;
- 演进友好:新能力等于新 part 类型,旧客户端对不认识的类型可以优雅降级,而不是解析崩溃。
面试官
这不就是 AI SDK 白送给你的吗?这一段里你自己做了什么?
SDK 给的是数据结构,不是展示结构。朴素做法是渲染时对 parts 直接一个 switch,那样上屏会很吵:连续七次文件读取,应该折叠成一组“浏览了 7 个文件”;todo_write、delegate、ask_user 这些调用长得是工具,语义上却是待办清单、子 Agent、向用户提问,各配专用卡片才像话。所以我多做了一步:先用纯函数把 parts 折叠成“时间线条目”,再由组件按条目类型分发。展示结构和数据结构不是同一个结构,中间要一层 view-model。
// parts → 时间线条目:渲染前先折叠
for (const [index, part] of message.parts.entries()) {
if (isExcludedToolPart(part)) continue // artifact / 生图另有渲染位
if (isToolUIPart(part)) {
toolRun.push(part) // 连续的工具调用攒成一组
continue
}
if (part.type === "reasoning") {
flushToolRun()
entries.push({
kind: "reasoning",
streaming: part.state !== "done",
text: part.text
})
}
// 最后一段 text 是“正文”,不进时间线,单独走 Markdown 主渲染
}分发端于是变成一个按 kind 的 map:reasoning 渲染成可折叠的“思考了 X 秒”条目(流式时自动展开、结束自动收起);subagent-call 渲染成子 Agent 行;todo 渲染成待办清单;ask_user / propose_plan 渲染成提问卡和计划卡;其余落回工具组卡片。而正文——最后一段 text part——不进时间线,铺在时间线下方单独走 Markdown 主渲染:过程可以折叠,结论必须永远直接可读。整个工作区在运行中强制展开,正常跑完自动收起,中断或失败则保持展开,留给人检查。
面试官
代码块和表格呢?
顺手交给 Markdown 层。streamdown 内置 Shiki 高亮和复制按钮;GFM 表格默认可用,我只用 components 覆写了 table 一个组件、统一宽表格的样式;模型想画流程图,写 mermaid 围栏就行(securityLevel: 'strict',下半场会被追问)。这里有条原则:对 Markdown 渲染器尽量用 components 覆写,少用全局 CSS 干预——升级渲染器的时候坏得少。
面试官
图表。你一直在绕开图表。
因为 Etyon 没有引入图表库,这个我先说清楚。但这道题我有明确立场:别让模型直接生成图表标记(SVG、画布指令),让它返回结构化数据,图表由前端可控地渲染。在这套架构里它就是一个新的 data part:约定 data-chart 的 schema(图类型、序列、坐标轴),服务端往流里写,前端注册对应渲染器、拿图表库画。三条理由:
- 模型输出的是数据而不是几百行 SVG:省 token、少幻觉,还能做 schema 校验——数据不合法就降级渲染成表格,而不是画一张错图;
- 悬浮、缩放、主题适配这些图表交互是前端职责,塞给模型只会两头都做不好;
- 真需要“任意可视化”时,出口是 artifact:模型产出完整 HTML,进沙箱 iframe 渲染,和对话流隔离——下半场会展开这一层的安全模型。
这个扩展点不是纸上谈兵,Etyon 里已经有一排 data part 在这么工作:data-chat-request-phase(请求阶段)、data-todo(待办快照,整包替换)、data-subagent-start/chunk/end(子 Agent 进度)、data-workflow-progress(workflow 阶段与完成计数)。图表只是这个扩展点上的下一个类型——路是通的,只是我还没走这一步。
面试官
那你说的“通用”,到底是什么意思?给我一个能验收的定义。
一个稳定的分发器加一组可插拔的渲染器。具体是三个扩展点:
- Markdown 层负责一切呈现型内容(文本、代码、表格、Mermaid),扩展点是 components 覆写;
- part 层负责一切状态型内容(工具、审批、待办、子 Agent),扩展点是新 part 类型加对应渲染器;没有专属渲染器的工具落回通用工具卡;
- 未知类型必须有兜底:协议先于 UI 演进时,旧界面宁可渲染一个中性的占位块,也不能崩、不能白屏。
验收方式也很直接:加一种新内容,如果只改这三处中的一处、不动分发器,那它就是通用的。至于复制、点赞点踩、重新生成、编辑重发这些消息级能力,它们挂在气泡的操作条上,和内容渲染彻底解耦——下半场第一题就是它。
中场:上半场被按住的三个地方
面试官合上笔记本:“先到这儿,休息十分钟,下半场换个人问你具体实现。”
复盘一下,上半场我被追到边界、只能承认的有三处,而它们恰好是我认为最值得在面试里主动交代的三类:
- 推理出来的数字:50ms 的节流窗口、48px 的贴底阈值,都有推理和边界,但没有实测数据——单人项目够用,团队项目该补测;
- 没做的能力:没有 SSE 断流重连(恢复的单位是轮次不是连接)、没有虚拟化、没有图表库——每一个我都能说出“什么时候必须做、做之前先做什么”;
- 被环境约束的结论:整份消息历史、轮次级恢复、内存 broker,全都建立在“本地、单用户、进程崩了才算断”这个前提上——换个宿主,结论要重算。
上半场如果只留一句:我把 Agent 引擎放在本地一个 HTTP 服务后面,前端通过 /api/chat 用 SSE 和 AI SDK v7 的消息流协议跟它交互;因为 Agent 会流式输出、会调工具、会停下来等人批准、还可能被中断,我尽量把状态编码进每次都完整发送的消息历史里,让审批和恢复都变成“重新发一个请求”,而不是去维护一条脆弱的长连接;渲染这一侧则是三层削减加一条“滚动主权归用户”的规矩,再加一层把 parts 折叠成时间线的 view-model。
下半场在这里:停止与重新生成、工具状态卡、以及安全——面试官会等着听我说“DOMPurify”。