面试
1. 什么是 IO,怎么优化 IO
我来讲一下吧:就是在我们客户端开发的环境下,io就是cpu与外部设备比如说键盘网络内存等之间的一个数据交换,但是他主要的问题是通常来讲cpu是比外部设备快的,不能每次都让cpu进行等待否则效率太低,主要的瓶颈在于用户态到内核态之间的转换和数据拷贝的次数。优化的话首先可以想到从数据拷贝的方面,我们可以用0拷贝或者buffer缓冲,前者通过mmap进行文件映射到进程的虚拟内存空间,后者则是用块的方式进行拷贝而不是按字节来减少系统调用。其次我们可以用异步io来实现优化,不让主线程一直等待,放到后台,或者使用kotlin协程,这样可以防止app掉帧,使得io线程不影响cpu的渲染,最后是数据方面我们可以进行数据压缩或者序列号优化,从数据层面把传入数据变小,比如plotocol buffer,JSON 是字符串形式,解析时需要大量的字符串处理和内存分配;而 Protobuf 是二进制格式.但实际上IO 优化往往伴随着 Cache(缓存)其实最好的 IO 优化就是不发生 IO,优先从内存读,规避磁盘 IO. “既然 mmap 这么好,为什么我们不把 App 里所有的文件读写都换成 mmap?它有什么局限性或者风险吗?”mmap 映射大文件会占用虚拟地址空间,在 32 位系统上可能会导致内存耗尽。
2.多维度,向量数据库,ai幻觉
向量数据库主要是用在 RAG 这种场景里。一般流程就是先把知识文档切成小块,做成embedding存进去;线上用户提问的时候,把问题也转成向量,去库里找最相似的几个片段。 找到之后通常还会做一层权限过滤和简单重排,再把这些内容拼到 prompt 里给大模型,这样能明显减少幻觉、回答也更靠谱。 解决幻觉我们主要不是指望模型本身,而是从工程上去兜底。大概做了三件事。 第一是上了 RAG。模型不会直接瞎生成,而是先去我们本地的教学案例库里查相关内容,再基于这些内容回答,这样基本能避免那种“编知识”的情况。 第二是把 system prompt 卡得比较死。我们会明确它的角色,比如就是一个翻转课堂专家,而且输出必须按 O-PIRTAS 那一套结构来,不能乱发挥。这样至少格式和逻辑不会跑偏。 第三是加了一点 few-shot,就是在 prompt 里放标准教案示例,让它照着那个风格来写,这个比纯文字要求要稳定一点。 另外我们也没完全指望一次生成就对,前端做了兜底,比如可以重新生成分支,或者用户自己改 prompt,再让模型继续生成,相当于一个人工纠偏的过程。
3.SSE
我们做了一套流式输出,核心是让模型生成的内容可以实时返回给前端。 后端这边是基于 HttpServletResponse 手动推流,模型每返回一段内容就写入 response,然后立即 flush,保证数据能及时传到前端。 前端用 Axios 的 onDownloadProgress 去监听数据流,拿到增量内容后做解析并更新页面,所以用户看到的是一个逐字生成的效果。 同时支持中断控制,用户可以随时停止生成,后端也会同步终止请求,避免资源浪费。 "SSE和nginx缓存" Nginx 如果开了 buffering,会把 SSE 的数据先攒一段再发,流式效果就没了。 我们的做法是后端在响应头加 X-Accel-Buffering: no,这是 Nginx 的一个控制字段,用来关闭当前请求的缓冲;同时配合 Content-Type: text/event-stream 和 Cache-Control: no-cache。 必要的话也会在 Nginx 配置里把 buffering 和 cache 关掉,这样可以保证数据是一段一段实时推到前端的。 "SSE要是输出到一半异常中断了怎么办?"
- 后端:用 SseEmitter 推送时,send/flush 抛 IOException(Broken Pipe)就视为断连;捕获后立即停止下游大模型请求并释放资源,避免继续消耗 Token。
- 落库:流式内容先在内存累加,收到 [DONE] 才把完整消息落库并标成功;异常中断时,按审计要求可把已生成片段落库,但标记“中断/失败”。
- 前端:监听 onError/超时等异常,停止打字机和 loading,并提示“输出中断”。
- 补偿:提供重试(带 conversationId、parentMessageId);若系统/模型故障且有效内容很少,则回滚/返还预扣电量,网络问题则保留状态并提示检查网络。
4.上下文
上下文这块我们是用“数据库 + 滑动窗口 + 前端缓存”一起做的。 后端所有对话都会落库,需要上下文时按会话 ID 拉历史消息; 为了控制 token 和长度,我们只取最近几条作为上下文,相当于一个滑动窗口; 前端这边会把当前会话存在 LocalStorage 里,刷新页面也能马上恢复聊天记录。 这样既保证了上下文效果,也把性能和成本控制住了。 “上下文很长怎么办” 每次用户发消息,我们会组一个 message 列表给模型: 最前面是 system prompt,中间是历史上下文,最后是用户这次的问题。 如果上下文很多的话,我们不会全带,只取最近几轮对话,相当于做了个滑动窗口,保证模型有短期记忆,同时把 token 控住。 另外我们也支持用户手动关掉上下文,这种情况下就只传 system prompt 和当前问题,相当于开一个新对话。 同时会过滤掉一些异常或者中断的消息,保证传给模型的都是有效内容
5.重试、修改、分支
我们这个“消息分支”的核心是做了一个树状结构,而不是简单覆盖更新。 用户点击重试的时候,其实是基于当前消息的 parentMessageId 再生成一条新的回复,相当于在这个节点下面多开一条分支,而不是替换原来的结果。 用户修改历史消息也是一样,不会删掉原来的内容,而是在这个位置重新分叉出一条新的对话路径,这样每一条路径都是独立可追溯的。 后端存储上,每一条 message 都是独立记录,通过 parentId 串起来。即使是同一个问题的多次生成,每一条回复都有自己独立的 messageId、token 消耗和时间信息。 前端这边会把同一节点的多个结果做成分支列表,支持类似 1/2、2/2 的切换,可以在不同生成结果之间来回切换,并且是按需渲染的,不会一次性全部展开。 这样做的好处是避免“好的回答被覆盖”,同时也符合大模型输出不稳定的特点,用户可以自己挑最满意的那一条。 “你的项目中有用到哪些模型?有没有遇到像是海外的模型服务中断的问题?” 我们这个项目是做一个多模型集成平台,接入的模型比较多。 海外这边主要是 GPT-3.5、GPT-4,还有一些像 Claude;国内的话包括 DeepSeek、文心一言、通义千问、星火、ChatGLM、豆包这些;另外也支持本地模型,比如用 Ollama 或 Langchain 本地跑,或者通过 Dify、Coze 这种平台做接入。 在实际使用中,海外模型确实会遇到一些不稳定的问题,比如网络不通、接口延迟高,甚至偶尔被限流。 我们这块做了三层处理。 第一层是网络层的代理和中转,比如支持 SOCKS5 代理,同时也可以通过海外 VPS 做 Nginx 反向代理,把请求转发到 OpenAI,这样解决基础的可达性问题。 第二层是 Key 管理,我们做了一个 Key 池,每次请求会随机选一个 Key,同时会更新 Key 状态,用来分摊额度压力,避免单 Key 被打爆。 第三层是模型容灾,也就是多模型切换。如果 OpenAI 不稳定,会自动或者手动切到国内模型,比如 DeepSeek 或文心一言,这一层是通过统一的 ModelService 做封装的,所以业务侧不用感知具体模型。 在性能上我们也做了一些优化,比如请求设置了合理的超时控制,同时配合 SSE 流式输出,让用户即使在长生成情况下也能尽早看到返回内容,减少等待焦虑。
6.你用过什么好用的模型?
不同模型我们是按场景来用的,没有固定只用一个。 比如处理超长文档或者代码库分析,会用 Gemini,因为它上下文特别长,可以一次性看很多内容。 日常写代码、做逻辑推理或者生成比较精细的文本,主要用 Claude,它在代码质量和表达稳定性上比较好。 如果是需要语音或者多模态,比如实时交互或者看图这种,会用 GPT,它延迟低,交互体验比较顺。 如果是本地部署或者想控制成本,比如自己跑模型或者做微调,会用 Llama 这一类开源模型。
7.你做过令你印象最深的题
香港区域赛K题,这题是给一个数组,要求在所有循环移位中,找到一个起点,使得该移位对应的“前缀最大值序列”在字典序意义下最小。前缀最大值序列本质是记录数组扫描过程中最大值不断更新的过程,因此不同起点的差异其实体现在“哪些位置触发了最大值更新以及更新的顺序”。我的思路是先把问题转化为结构问题:每个位置只关心它右侧第一个比它大的元素,从而建立类似笛卡尔树的“向上跳转关系”,把前缀最大值变化过程结构化。之后每个起点对应一条确定的结构路径,我再用二分哈希把这条路径上的值变化和区间贡献编码成可比较的哈希值,最后通过比较所有起点的哈希结果得到答案。
8.TCP、UDP
TCP 和 UDP 的区别 a. 连接 TCP:面向连接(三次握手、四次挥手) UDP:无连接 b. 可靠性 TCP:可靠(确认、重传、校验,保证有序、不丢、不重) UDP:不可靠(可能丢包、乱序) c. 特点 TCP:面向字节流、有状态、头部大、开销大 UDP:面向报文、无状态、头部小、开销小 d. 场景 TCP:HTTP、邮件、文件传输等要可靠、有序的场景 UDP:视频、语音、直播、游戏、DNS 等要低延迟、能接受少量丢包的场景
9.websocket、SSE
对于 AI 聊天这种流式输出的场景,我们主要用的是 SSE,因为它跟业务模型很契合。整体是“用户发一个请求,模型持续返回一长段文本”,这种是典型的单向流式通信,所以 SSE 就够用了,不需要 WebSocket 那种双向能力。 相比 WebSocket,SSE 是基于 HTTP 的,不需要升级协议,所以在 Nginx、CDN 或代理环境下更稳定,不容易被拦截。同时它本身就支持断线自动重连,前端基本不用额外处理重连逻辑。后端实现也比较简单,只需要维持一个 HTTP 连接,然后不断 flush 数据就可以。 在我们项目里,SSE 是主通道,用来做 AI 对话的实时输出。 WebSocket 我们也保留了,但主要用于一些需要双向通信或者强实时的场景,比如实时通知、多端同步、或者像用户在线状态变化、系统消息推送这种“服务器主动推给所有客户端”的情况,这种场景用 WebSocket 会更合适。
10. 讲一下编译的流程
首先是预处理:处理宏、#include、条件编译,生成“展开后”的源码。 然后是词法分析:把字符流切成 Token(关键字/标识符/数字等)。 接着是语法分析:按语法规则把 Token 组装成 AST(抽象语法树)。 然后是语义分析:做类型检查、作用域/符号表检查,确认声明与可访问性等。 接下来生成中间代码并优化:生成 IR(如三地址码/LLVM IR),做常量折叠、死代码删除等优化。 然后目标代码生成:生成汇编/机器码(.s/.o)。 最后是链接(很多面试也算在“编译”广义里):把多个 .o 和库合成可执行文件,包含符号解析、重定位,以及静态/动态链接(libc、so/dll 等)。
11. 堆和栈的区别
a. 管理方式 栈:由编译器/系统自动分配与回收(函数进出栈) 堆:手动申请释放(C/C++ malloc/free),或由 GC 管理(Java/Go) b. 生命周期 栈:随函数作用域结束自动释放 堆:直到显式释放或被 GC 回收才释放 c. 性能 栈:分配回收快(移动栈指针) 堆:相对慢(需要管理空闲块,可能碎片化) d. 空间与限制 栈:空间通常较小,过深递归/大局部变量可能栈溢出 堆:空间通常更大,但可能内存泄漏或碎片化 e. 存放内容(常见说法) 栈:局部变量、函数参数、返回地址等 堆:动态创建的大对象/需要跨作用域存活的数据 f. 访问方式 栈:连续、后进先出(LIFO),局部性好 堆:按指针引用访问,不保证连续
12. LateX
我们这个项目里公式其实分两块:一块是前端聊天页面实时展示,另一块是后端导出 Word/PDF。
前端这块比较简单,就是模型返回的内容本质还是 Markdown,我们先用 marked 解析一遍,然后把里面的 和 $$...$$ 这种公式提取出来,用 KaTeX 转成 HTML 渲染到页面上,代码块用 highlight.js 做高亮。之所以用 KaTeX,是因为它渲染比较快,适合这种流式输出的场景,用户可以一边生成一边看到公式,不会卡。
MathJax 我们也试过,但在这种流式、不完整内容的情况下稳定性一般,而且比较重,所以后来主链路就换成 KaTeX 了。
后端导出的话是另一套逻辑,因为 Word 和 PDF 不能直接用前端渲染好的 DOM,所以我们在服务端重新解析 Markdown,把公式识别出来,然后用 jlatexmath 渲染成图片,再插到文档里,PDF 用 iText,Word 用 POI。
整体这么设计主要是为了把“页面展示”和“文档导出”分开,各自用最合适的方案,这样稳定性和效果都会更好。
