Linums项目深挖
# Linums
0. 破冰与自我校准(5 分钟)
Q0-1 你用 2 分钟介绍这个项目:解决什么问题?核心亮点是什么?
追问
- 你认为“最难的 3 件事”分别是什么?为什么?
回答
- 这是一个“多模型 AI 聊天”应用,核心是“低延迟流式输出 + 可视化推理(thinking)+ 工具调用(web_search / generate_image)+ 语音输入输出 + 会话管理与分享”。
- 最难的点主要有三类:
1) SSE 增量流 + UI 性能:后端把不同模型供应商的上游流统一成“前端可消费的业务事件”(thinking/answer/工具相关事件/完成),前端再用“帧级缓冲 + 虚拟列表 + 流式内容预处理”,把高频 chunk 的更新压到浏览器接受的可控频率,避免每个字符都触发渲染,同时保证滚动、Markdown、媒体块在流式时不抖不炸。
2) 工具调用的增量聚合 + 多轮 tool loop:上游会把工具调用信息分片吐出(尤其是参数 JSON),服务端需要边转发文本边收集碎片、在参数“足够完整”时尽早并发执行工具;工具结果要回写到上下文后再发起下一轮模型请求,并且要有白名单、可用性探测和轮次上限,避免幻觉工具与无限循环。
3) 体验一致性:要把“中断/重试/编辑重发/切会话/滚动吸底/缓存秒开”等行为做成一个一致的状态模型:用户中断时要能保存已生成内容,重试/编辑要保持时间线符合直觉,切会话要避免流式状态串线,且要兼顾“用户上滑阅读时不强行吸底”。
2. 流式 SSE:协议、解析、渲染性能(高频深挖)
Q2-1 你为什么选择 SSE 而不是 WebSocket?
追问
- SSE 在代理/CDN、移动网络下的坑有哪些?你怎么规避?
参考回答
- 这个项目的核心数据流本质上是“服务端 → 客户端的单向连续输出”:模型按 token/片段不断产出内容(同时夹杂 thinking、工具调用进度、工具结果等事件),前端只需要稳定地消费这条单向流并渲染即可;在这种场景下 SSE 更贴合问题形态。
- 从工程成本看,SSE 基于标准 HTTP:浏览器端用普通
fetch就能读到流,服务端也只需要返回一个持续写入的响应,不需要额外的 WebSocket 协议栈、心跳协商、消息分帧与双端重连状态同步;对 Next.js 这种“路由处理器 + 流式 Response”模式也更自然。 - 从可靠性看,SSE 通常比 WebSocket 更容易穿透企业代理/部分 CDN/弱网环境(因为它就是长连接的 HTTP 响应);而且调试体验更好:可以把内容统一包装成结构化事件(例如答案片段、推理片段、工具开始/进度/结果、完成),前端按事件驱动状态即可。
- 当然 SSE 也有坑:长连接可能被中间层缓冲/超时、移动网络易断。我的规避思路是:响应头禁止缓存与缓冲、必要时加轻量心跳;前端支持 Abort 中断与“已生成片段回传保存”来兜底;并对异常结束做可恢复的重试策略(避免用户感觉“断了就全没了”)。
Q2-2 你前端怎么把高频 chunk 变成“看起来连续、又不卡”的 UI?
追问
StreamBuffer为什么用requestAnimationFrame批量刷新?为什么不是节流/防抖?
参考回答
- SSE chunk 可能达到 80~120 次/秒,如果每次都更新 store 会导致大量渲染。
- 我的目标不是“把每个 token 立刻渲染出来”,而是让用户感知到连续输出,同时让浏览器只做它擅长的节奏(按帧渲染)。核心策略可以概括为三层:
1) 事件分层:把上游流统一成结构化事件(例如推理片段、答案片段、工具开始/进度/结果、完成),前端按事件更新对应区域,避免把所有内容都塞进同一个字符串里导致频繁全量重渲染。
2) 帧级缓冲:对“最频繁变化的文本”做缓冲合并,把短时间内到来的多个 chunk 合并到一帧里再落到状态中;这样视觉上仍然是连续的,但渲染频率被限制在浏览器可承受范围内。
3) 大列表虚拟化:聊天历史本质是一个可能无限增长的列表,只渲染可视区域附近的消息,避免 DOM 规模随着会话长度线性膨胀。 - 关于追问:
- 为什么用
requestAnimationFrame批量刷新,而不是节流/防抖? 流式输出的体验追求的是“稳定、均匀、持续地动”。requestAnimationFrame的语义是“在下一帧绘制前执行”,天然适合把同一帧内到达的多个 chunk 合并成一次 UI 提交:更新更贴近渲染时机、减少布局抖动,也不会把主线程压到每个字符都更新。
- 为什么用
Q2-3 你是怎么解析 SSE 的?边界条件是什么?
追问
- 你用的是“按
\n切行”(lib/utils/sse.ts),而 SSE 标准是事件由空行分隔,你为什么这样也能工作? - 如果 provider 返回的 JSON 里出现换行,会不会炸?怎么改更稳?
参考回答
- 解析 SSE 我会按“网络层切分 + 业务层消费”的思路做成一个小管道:
1) 读取:用流式读取不断拿到字节块;每次把字节块解码成字符串并拼到一个缓冲区里。
2) 切分:从缓冲区中切出“完整的一批事件/行”,把剩下的半截继续留在缓冲区,等待下次数据到来后再拼接。
3) 提取与解析:对每个完整事件提取data:部分,尝试解析为 JSON(或识别结束标记),解析失败则容错跳过,避免把整条链路打断。
4) 分发:解析成功后按事件类型分发到 UI(例如推理片段追加到推理区、答案片段追加到正文、工具事件更新工具面板、完成事件触发收尾与落库/状态收敛)。 - 这个项目里采用的是“多数供应商都会按行输出
data: {...}”的流式格式,所以实现上可以按行切分就能跑通;但我会把解析器设计成可升级:如果遇到更严格的 SSE(一个事件可能包含多行data:、事件以空行分隔),就把切分逻辑从“按行”升级为“按空行分隔事件并聚合多行 data”,而业务消费层不需要改。 - 我会重点覆盖这些边界条件:
- UTF-8 跨 chunk:一个字符可能被拆在两个字节块里,所以必须用“可流式解码”的方式解码并依赖缓冲区拼接,不能假设每次都是完整字符串。
- 半截 JSON / 半截事件:JSON 可能被拆开,必须通过缓冲区等待完整再解析;对解析失败要容错而不是抛异常。
- 结束信号与异常结束:既要识别正常结束(例如
[DONE]或自定义完成事件),也要处理网络断开/中断(Abort)这种“没有正常结束信号”的情况,UI 需要能收敛到可恢复状态。 - 多类型事件并发:同一条流里会混合推理、答案、工具事件,解析器要保证事件顺序可被稳定消费,且不同区域的更新互不干扰。
- 可观测与调试:当解析失败或出现非预期格式时,至少要能定位是哪类事件/哪段 data 出问题,而不是用户只看到“卡住了”。
- UTF-8 跨 chunk:一个字符可能被拆在两个字节块里,所以必须用“可流式解码”的方式解码并依赖缓冲区拼接,不能假设每次都是完整字符串。
Q2-4 你为什么不用 EventSource 接收 SSE,而是选择 fetch?
追问
EventSource自带自动重连,你不用是不是损失了稳定性?- 用
fetch读流在浏览器兼容性上有没有坑?你怎么处理?
参考回答
- 这个项目的“模型生成”是典型的一次请求对应一条可控的长流:需要携带较多的请求参数(模型选择、开关、上下文信息等),并且需要在用户点击“停止”时立即中断。相比
EventSource(只支持 GET、参数表达能力弱),fetch更适合用 POST 发送结构化请求体,同时也更方便做取消与收尾动作。 - 我还需要把 SSE 当作“业务事件通道”来消费:同一条流里不只是答案文本,还会有推理片段、工具调用过程、工具结果等多类型事件。用
fetch读取ReadableStream更容易实现“解析 → 分发 → 状态收敛”的管道,并且能顺便读取响应头中的一些服务端提示信息,用于及时同步前端状态。 EventSource的自动重连在聊天流式场景里并不一定是优势:中途重连很容易出现重复片段、顺序错乱或 UI 状态不一致,除非额外设计事件 id、断点续传与幂等逻辑。这个项目更倾向“由业务显式控制重试”,让重试行为对用户可见且可解释。
3. 工具调用(Function Calling)与多轮 Tool Loop(AI 深挖)
Q3-1 你是如何把“工具调用”接进流式输出的?
追问
tool_calls是增量分片吐出来的,你怎么把碎片拼成完整 JSON?- 你为什么要限制
MAX_TOOL_ROUNDS = 5?这个数字怎么来的?
参考回答
- 整体思路是“把工具调用当成流式链路中的一个子状态机”:服务端持续读取模型的增量输出,文本部分实时下发给前端;当检测到模型开始输出工具调用时,进入“收集工具调用碎片 → 组装参数 → 触发执行”的流程。
- 参数往往是分片输出的 JSON,所以需要先按调用序号聚合碎片,再用“完整性门槛”(能被解析为完整 JSON)判断是否可以执行;这样可以尽早并发执行工具,减少等待。
- 工具执行结束后,把结果整理成模型可理解的“工具消息”追加回上下文,再发起下一轮模型请求继续生成;同时设置轮次上限/时间上限,避免陷入无限工具循环,并在每轮记录耗时与失败原因用于排障。
Q3-2 你怎么做工具的“白名单”和“按需开启”?
追问
- 为什么
web_search要用户手动开启,但generate_image你让它“始终可用”(handleChatRequest)? - 如果用户不希望模型自动生图,你怎么支持“强制禁用”?
参考回答
- 我把“工具是否存在”分成三层:
1) 系统层可用性:根据环境变量与外部依赖可达性决定工具是否注册(例如搜索 key 是否配置、生图存储链路是否可达)。
2) 请求层开关:同一个工具在系统可用的前提下,也未必每次请求都允许用(例如搜索涉及成本/隐私,所以默认关闭,需要用户显式打开)。
3) 执行层白名单校验:即便模型“说要调用某工具”,也必须通过白名单校验才能执行,防止模型凭空编造工具名或越权调用。 - 如果用户希望禁止某类工具(例如不自动生图),就在“请求层开关/白名单生成”阶段直接过滤掉,并在提示词里明确告知模型该能力不可用。
5. Markdown 渲染、安全与 XSS(高级前端必问)
Q5-1 你为什么在客户端启用了 rehypeRaw?这会带来什么安全风险?
追问
- 这个项目里,Markdown 内容来自 LLM(不可信),启用 raw HTML 代表什么?
- 你会怎么修?用什么库/策略?服务端渲染(
server/utils/markdown.ts)同样要怎么处理?
参考回答
rehypeRaw能让 Markdown 里的 HTML 生效,但如果内容来自 LLM/用户输入,就存在 XSS 风险(<img onerror>、<script>等)。- 正确方案:默认禁用 raw HTML;如必须支持,加入
rehype-sanitize并配置 allowlist(仅允许p/a/code/pre/strong/em/...等安全标签与属性),并对链接协议做限制(禁止javascript:)。 - 服务端的
remark-html({ sanitize:false })同样不安全;尤其我还会在预处理里插入<img src="${url}">,必须对url/alt做转义与协议校验。
Q5-2 你实现了自定义代码块(weather/chart/image),为什么要这么做?
追问
- 流式输出时 JSON 可能不完整,你怎么避免“半截 JSON 把页面炸掉”?
- 如果模型输出了非常大的 chart 数据,怎么防止渲染阻塞?
参考回答
- 自定义块把“结构化数据”从纯文本里抽出来,前端能用组件渲染(
features/chat/components/MessageContent/blocks/*)。 - 流式时我在
MessageContent预处理未闭合代码块:媒体块如果 JSON 不完整就先隐藏,完整就补闭合,让 ReactMarkdown 可解析。。
6. 会话与消息:一致性、分页、缓存
Q6-1 你如何保证“流式生成过程中中断”不会丢数据?
追问
- 前端
abortStream()为什么还要调用/api/message/[id]/save-partial? - 服务端在正常结束时又会落库(
persistMessage),如何避免覆盖/竞态?
参考回答
- 我把“落库”分成两条路径:
- 正常结束路径:流自然结束时,由服务端把本次生成的完整内容一次性写入数据库,保证服务端为最终真源。
- 异常/中断兜底路径:用户主动中断或网络断开时,服务端不一定走到自然结束,所以前端把“已收到的片段(答案/推理/工具调用状态)”回传给后端保存,尽量不丢内容。
- 竞态上要注意两边可能同时写入:工程上可以用“幂等更新”(按消息 ID 写同一行)、或者做更严格的合并/版本控制(例如只允许内容追加、或用更新时间比较)来避免覆盖。
Q6-2 你的消息分页为什么用 createdAt + cursor,而不是 id?
追问
MessageRepository.findPaginated先查 cursorMessage 再按时间比较,这样做的复杂度与索引要求是什么?- 在高并发下
createdAt相同怎么办?
参考回答
- 用
createdAt游标更直观,且 Prisma 查询表达能力稳定;配合索引@@index([conversationId, createdAt])(schema 已有)。 - 同毫秒并发可能出现相同时间戳,严格的话需要二级排序(
createdAt + id)或用单调递增序列;目前通过“用户消息与 assistant 消息时间差 +1ms”降低冲突概率,但仍建议升级为复合游标。
Q6-3 你为什么要做“会话内消息缓存”?
追问
loadMessages里“有缓存就先展示,后台静默刷新”,这会带来哪些一致性问题?- 你如何避免切会话时把上一个会话的流式状态带过来?
参考回答
- 缓存是为了切会话时“秒开”,减少等待;静默刷新用于最终一致。
- 一致性问题:可能短暂展示旧数据;我会在后台拉到最新数据后做去重与顺序修正,并用“是否正在发送/是否正在流式生成”的信号避免把本地正在生成的消息覆盖掉。
- 切会话的关键是“清理现场”:如果上一会话还在流式生成,先安全中断并保存已生成片段;滚动与吸底状态要按会话重置,避免把 A 会话的自动滚动策略带到 B 会话。
7. 鉴权与安全(全栈视角)
Q7-1 你为什么同时存在 NextAuth(OAuth/Credentials)和自定义 JWT Cookie?
追问
getCurrentUserId()为什么“优先 NextAuth,失败再 JWT”?迁移策略是什么?- 你如何统一前端登录态?避免出现“页面是登录的、API 却 401”?
参考回答
- 这是历史演进:早期用自建 JWT(
/api/auth/login),后来引入 NextAuth 以支持 OAuth。server/auth/utils.ts做双路兼容,方便平滑迁移。 - 迁移策略:逐步将所有入口改为 NextAuth;保留 JWT 仅用于旧客户端;最终移除 JWT 路由,降低复杂度与安全面。
- 统一登录态:前端只认 NextAuth session(或只认 JWT),不要两套并行;否则会出现状态不一致,需要明确“单一真源”。
Q7-2 你存用户的 API Key(User.apiKey)是明文,这样安全吗?
追问
- 如果数据库泄露怎么办?你会如何加密?密钥放哪里?
- 如果要支持多 provider 多 key(OpenRouter/BigModel/SiliconFlow),你的表结构怎么设计?
参考回答
- 明文不够安全。改进:使用 KMS 或服务端对称加密(AES-GCM)存密文,密钥放在环境变量或 KMS;同时对 key 做“只写不可读”的 UX(回显打码)。
- 多 key:可以拆表
UserProviderKey(userId, providerId, encryptedKey, createdAt, lastUsedAt),并加唯一索引(userId, providerId)。
Q7-3 你有哪些“明显的安全风险点”?你会怎么做安全基线?
追问
- Markdown XSS 之外,还有哪些?(文件上传、图片下载落盘、监控上报、分享页 SSR)
- 你会怎么做 rate limit / abuse prevention?
参考回答
- 风险点:1) Markdown raw HTML(已讨论),2)
/api/monitor未鉴权可能被刷库,3)downloadAndSave(remoteUrl)若将来 remoteUrl 可控会有 SSRF 风险,4) 分享页服务端渲染 HTML 未 sanitize。 - 基线:加 rate limit(按 userId/IP + 路径),对 public API 加 captcha/签名;对外部 fetch 做 allowlist;统一 CSP;日志脱敏;依赖漏洞扫描。
8. 语音链路:录音、STT、TTS、播放体验
Q8-1 你录音为什么选 MediaRecorder?兼容性与格式怎么处理?
追问
- 你强制
mimeType: 'audio/webm',Safari 怎么办? - 录音流你什么时候释放麦克风?为什么要延迟 1 秒?
参考回答
- MediaRecorder API 简洁、浏览器侧直接产出 Blob;STT 端点支持多音频类型(
app/api/speech/route.ts)。 - 兼容性:Safari 对 webm 支持弱,需要动态检测
MediaRecorder.isTypeSupported,fallback 到audio/mp4或用 WebAudio 手动编码。 - 释放麦克风我在 stop 后延迟 1 秒,避免某些浏览器立即 stop track 导致最后一段数据丢失;更严谨可以监听 dataavailable flush 完成再释放。
Q8-2 你 TTS 为什么直接把上游 response.body 流透传给前端?
追问
- 你怎么处理“用户连续点播放”导致的并发与资源泄漏?
- 浏览器自动播放策略导致 play 被拒绝,你如何让 UX 更好?
参考回答
- 透传流能降低延迟(首包更快),并且避免服务端落盘与二次 IO。
- 并发:前端
useAudioPlayer在新请求前 revoke 旧 URL,必要时用 AbortController 取消旧的 TTS 请求。 - 自动播放:捕获
play()异常,提示用户点击播放按钮触发手势;或者把播放动作绑定到明确的用户交互事件。
9. 性能:虚拟列表、渲染策略、包体
Q9-1 你为什么要用虚拟滚动?你是如何估算高度和测量的?
追问
estimateSize里用内容特征猜高度(MessageList),这可能带来什么问题?- 思考面板/图片块出现时,高度会变化,虚拟列表怎么保持滚动稳定?
参考回答
- 聊天历史可能很长,非虚拟滚动会导致 DOM 膨胀和重排,虚拟列表能把渲染量控制在可视区附近。
- 估算不准会导致滚动跳动;我用
virtualizer.measureElement在真实渲染后测量修正。 - 媒体块高度变化要触发重新测量(比如图片 onLoad 后调用 measure),并处理“用户是否在底部”的自动滚动逻辑。
Q9-2 你如何定位“首屏慢/渲染卡顿/内存涨”的问题?
追问
- 你在项目里做了哪些埋点?TTFB/首 token 延迟怎么测?
- 你会如何区分是“网络慢”还是“前端渲染慢”?
参考回答
- 我有监控上报入口(
/api/monitor)+ trace helper(lib/monitor/trace-helper),可以记录从“发送请求 → 首 chunk”到“complete”的时间。 - 网络 vs 渲染:网络看 Response headers/TTFB,渲染看 React commit 时间、长任务(PerformanceObserver)、以及 rAF flush 频次;必要时把解析与渲染分离(Web Worker)。
10. 数据库与 Prisma:约束、索引、迁移
Q10-1 你给 Conversation/Message 打了哪些索引?为什么?
追问
Conversation @@index([userId, updatedAt(sort: Desc)])对查询有什么帮助?- 如果消息量暴涨,你会如何做归档/冷热分层?
参考回答
- 会话列表按 userId + pinned/updatedAt 排序,索引能显著减少排序与扫描;消息分页用 conversationId + createdAt 索引。
- 量大后:按会话分区(Postgres partition)、或把旧消息归档到冷表/对象存储;并给全文搜索引入专用索引(tsvector)。
12. 系统设计扩展题
Q12-2 如果要把 LLM 成本降 50%,你有哪些手段?
追问
- 从 prompt、上下文裁剪、缓存、模型路由角度分别说方案。
- 工具调用如何减少不必要搜索/生图?
参考回答
- Prompt:系统提示词精简、结构化输出约束更紧;上下文:只取最近 N 条 + 摘要记忆;缓存:相同问题/相同会话状态缓存结果;模型路由:简单问答用小模型,复杂推理/工具调用用强模型。
- 工具:对 web_search 加触发阈值(仅当问题含“最新/今天/实时”或置信不足),并做结果缓存与去重;生图强制用户确认或只在明确意图时开启。