字节 Agent 全栈研发一面题单:从 Agent 项目讲到 SSE 断线恢复
- 轮次
- 一面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
本贴是作者按主题整理的备考题单,题目来自多份字节相关面经,不是同一场面试的实录。
项目 / Agent
- 介绍一个 Agent 项目,从用户输入到结果展示走一遍,最难的点是什么?
- 意图不在预设范围里怎么办?什么时候追问,什么时候兜底?
- 长对话怎么压缩?如何避免把关键约束一起压掉?
- 多 Agent 怎么分工?规划器和执行器之间传哪些信息?
流式交互 / 服务端
- SSE 断线后怎么恢复?重连时如何避免消息重复?
- 工具调用参数分段返回,前端怎么拼?半截 JSON 和断流怎么处理?
- 消息队列里的结果怎么对应原请求?为什么不用数据库轮询?
- Tool Calling、MCP、Rules、Skills 有什么区别?各自适合用在哪?
基础 / 手写
- RAG 和长期记忆有什么区别?短期上下文、长期记忆分别存什么?
- 多用户调用同一个工具,Redis 怎么做限流?限流和限制并发有什么区别?
- MySQL 常见日志各管什么?联合索引的使用怎么分析?
- 手写一个并发上限为 2 的异步调度器,成功和失败都要释放名额。
《参考解析》
「从用户输入到结果展示走一遍」考的是你知不知道自己系统的边界。 合格的回答是一条带环节和失败点的链路:请求进来先做鉴权与配额校验,再进 Agent 循环(拼 system、检索记忆与知识、把可用工具的表一起发给模型),模型流式吐出文本与工具调用,服务端执行工具、把结果追回消息列表,再请求下一轮,直到模型不再调工具;输出侧走 SSE 分块下发,前端按事件类型渲染文本、工具卡片和错误。每一环都要能说出一个具体的坑:工具超时、模型返回非法 JSON、用户中途关页面、上下文超长。只说「调 OpenAI 接口然后显示出来」的人,一定会在「最难的点是什么」上卡住。
意图越界:先分「可枚举」和「不可枚举」两类。 可枚举的(闲聊、越权、明显不属于业务的问题)用一个轻量分类器或规则挡住,直接兜底;不可枚举但可能是本业务的问题,就追问一次,把范围收窄后再决定。判断标准不是「模型觉得像不像」,而是「错了之后用户能不能自己纠正」:能纠正就追问,不能纠正(比如会产生副作用的下单、删除)就先确认再执行。最差的做法是无条件追问,用户会烦;第二差的是无条件兜底,用户会觉得你没用。
长对话压缩的关键是先分级,再决定谁不能动。 一般做法是把消息分成三层:必须逐字保留的(system 指令、用户明确说过的约束、当前任务的中间产物)、可以摘要的(较早的对话轮次)、可以直接丢的(重复的寒暄、已经完成的工具返回)。压缩时先由规则或程序把第一层摘出来钉住,再让模型总结第二层,并强制总结结果里必须包含「用户给过的硬约束」清单。判定压缩有没有压坏,靠一个便宜的回归手段:压完之后塞一个探针问题(比如「我一开始说的预算上限是多少」),答不上来说明压坏了。
多 Agent 分工,先问「拆开之后靠什么对齐」。 规划器负责把目标拆成有依赖关系的步骤和每步的验收标准,执行器只拿「当前这一步 + 它需要的上下文 + 验收标准」,不拿全量历史——这是拆分真正的收益所在。传的东西要固定成结构:任务 id、目标、输入、期望输出格式、可用的工具白名单、上游产物引用。规划器和执行器的耦合点只有两处:任务契约和回传的结果结构,别让它们共享可变状态,否则多 Agent 就退化成「一个 Agent 分了几个函数」。
SSE 断线恢复的核心是「客户端要知道自己收到了哪」。 服务端给每个事件带上单调递增的序号(或消息 id),客户端断线重连时把这个序号带上,服务端从该序号之后续发;做不到就从最近一个完整消息边界重发。避免重复有两个层面:传输层用序号去重、应用层用消息 id 幂等。注意 SSE 自带 Last-Event-ID 请求头,浏览器原生 EventSource 重连时会自动带上,服务端读它即可,不需要自己发明一套。别用轮询兜底——那是把问题从「续传」变成「重复拉取」。
工具调用参数是分段流式返回的,前端不能每来一段就 JSON.parse。 正确的做法是往缓冲区追加字符串,只在括号配平(或服务端明确给出 finish_reason / 事件类型为完成)时才解析;渲染时可选地把已收到的部分参数先以「正在填写……」的形式展示,但绝不把它当成可执行的值。半截 JSON 的唯一安全处理是丢弃重来或等待,任何「猜着补齐」都会在参数里留下错误值。断流时同样按「未完成」处理并让模型重发,而不是把半截参数交给工具。
消息队列结果与请求的对应,靠的是一个全链路 id。 请求进来生成 requestId(或 traceId),随任务一起进队列;worker 完成后把 {requestId, result} 写入结果通道;网关侧维护 requestId → SSE 连接 的路由表,收到结果就推给对应连接。不用数据库轮询的原因很直接:轮询的延迟由轮询间隔决定、成本随在线用户线性增长、数据库还要承受无意义的空查;而长连接 + 消息通道是事件驱动,延迟是毫秒级,代价只是连接数。轮询唯一合理的场景是「结果必须持久、用户可能明天才来看」,那时候是查历史而不是等结果。
Tool Calling、MCP、Rules、Skills 是四个层次的抽象。 Tool Calling 是模型侧的协议:把函数签名告诉模型,模型输出「调哪个、传什么」。MCP 是连接层的标准协议,解决「工具从哪来、怎么被发现和调用」——它把工具服务做成独立进程,任何客户端都能接同一套工具。Rules 是约束层,是写进上下文的行为规范(用什么框架、不许动哪些目录、输出格式),本质是提示词的一部分,靠人维护。Skills 是能力包,把「一件事怎么做」的一整套说明、脚本和资源封装起来按需加载,解决的是上下文容量问题——不用把几十个流程全塞进 system。一句话记住:Tool Calling 是「能做什么」,MCP 是「工具从哪连」,Rules 是「必须怎么做」,Skills 是「这类活怎么干」。
RAG 与长期记忆解决的是两种不同的缺失。 RAG 补的是「模型不知道的外部知识」,内容是只读的、有出处的、可以随时重建索引;长期记忆补的是「这个用户/这个项目自己的历史」,内容是可写的、会变的、必须按人隔离。所以短期上下文放当前任务的对话与工具结果,长期记忆放用户偏好、既往结论、项目约定这类跨会话要用的东西。最常见的错误是把用户偏好写进知识库:那会让所有用户共享同一份记忆,而且改一次要重建索引。
Redis 限流与限制并发是两件事:一个管速率,一个管同时在场。 限流用令牌桶或滑动窗口(INCR + EXPIRE,或 Lua 保证原子),限制的是「单位时间内能进多少次」;并发限制用计数器或信号量(INCR 到上限就拒绝,任务结束 DECR),限制的是「同一时刻有多少个在跑」。只做限流挡不住长任务把连接池占满,只做并发限制挡不住瞬时打爆下游,所以工具调用这种场景两个都要:外面的令牌桶控 QPS,里面的信号量控并发。注意 DECR 必须放在 finally,异常路径漏减会让额度永久泄漏。
MySQL 的日志按「谁写的、写什么、干什么用」记。 redo log(InnoDB 层,物理日志,循环写)保证崩溃恢复的持久性;undo log(InnoDB 层,逻辑日志)支持回滚和 MVCC 读;binlog(Server 层,逻辑日志)用于主从复制与按时间点恢复;slow log 记慢查询用于优化;error log 记启动与错误;general log 记所有语句,排查时临时开、生产别开。联合索引的分析看最左前缀:(a, b, c) 上 where a=? and c=? 只能用 a,where a=? and b>? and c=? 里 c 用不上(范围查询后面的列失效),order by 也只有当前缀匹配时才能免排序。判断靠 EXPLAIN 的 key、key_len、Extra(出现 Using filesort / Using temporary 就是没走好),必要时 EXPLAIN ANALYZE 看真实行数。
并发上限为 2 的异步调度器,考点全在「失败也要释放名额」。 结构是一个运行中计数、一个等待队列、一个 acquire/release 配对:acquire 时若计数小于 2 就加一并放行,否则把 resolve 塞进队列;release 时若队列非空就取出一个直接放行(计数不变),否则计数减一。任务用 try/finally 包住,finally 里 release——这是唯一能保证超时、抛错、被取消三种情况都归还名额的写法。要加分的话再补两点:队列里等待的任务也要能超时退出(否则会一直挂着),以及 release 要幂等(同一个任务重复释放会破坏计数)。