Linums项目深挖

2026 年 2 月 16 日 星期一(已编辑)
24
1

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 出问题,而不是用户只看到“卡住了”。

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. 鉴权与安全(全栈视角)

追问

  • 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 加触发阈值(仅当问题含“最新/今天/实时”或置信不足),并做结果缓存与去重;生图强制用户确认或只在明确意图时开启。

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...