阿里巴巴 · 阿里云

AI Agent 算法一面面经

完整信息

30 条问法 · 27 道题

本篇目录

写在前面

【WeThinkIn出品】栏目专注于分享Rocky的认知思考与经验感悟,范围涵盖但不限于AI行业。

干货AIGC、AI Agent、LLM算法工程师/开发工程师面试面经秘籍分享(持续更新):WeThinkIn/AIGC-Interview-Book欢迎大家Star~

大家好,我是Rocky Ding。

一面

1. KV Cache是什么?为什么能加速大模型推理?

面试官: 有了解过KV Cache吗?它是什么作用,为什么能加速大模型推理?

面试官追问: 那KV Cache的代价是什么?长序列推理时显存怎么估算?

回答:

KV Cache 的本质,是用显存换取自回归解码阶段的重复计算。在 Transformer 的每一层注意力中,历史 Token 对应的 Key 和 Value 一旦计算完成,在后续生成步骤中通常不会改变,因此可以缓存下来。生成第 $t$ 个 Token 时,只需要计算当前 Token 的 Query、Key、Value,再让当前 Query 与前 $t$ 个位置的 Key、Value 做注意力,而不必重新计算全部历史 Token 的 Key、Value 投影。

需要精确区分两个阶段:

  1. Prefill 阶段。 一次输入整段 Prompt,普通全注意力的计算量仍近似为 $O(T^2)$。KV Cache 不会消除这部分完整上下文注意力计算。
  2. Decode 阶段。 每生成一个新 Token,只新增一份 K、V,并读取历史缓存。单步注意力计算随当前上下文长度近似为 $O(T)$;如果完全不缓存,还要反复重算历史 Token 的 K、V 和中间表示,代价会显著更高。

因此,不能简单说“整个生成过程从 $O(T^2)$ 变成 $O(T)$”。更严谨的说法是:KV Cache 避免了逐步解码中对历史 K、V 的重复投影与重复前向计算,使增量解码成为可能,但每个新 Query 仍需要与历史 Key 做注意力。

标准 Decoder-only 模型的 KV Cache 理论容量可近似写为:

<div>

$M_{\mathrm{KV}} = 2LBTH_{\mathrm{KV}}D_hS,$

</div>

其中:

  • $2$ 表示 Key 和 Value 两份缓存;
  • $L$ 是 Transformer 层数;
  • $B$ 是实际并发序列数;
  • $T$ 是每条序列当前缓存的 Token 数;
  • $H_{\mathrm{KV}}$ 是 KV Head 数;
  • $D_h$ 是每个 Head 的维度;
  • $S$ 是每个元素的字节数,例如 FP16/BF16 通常为 2 字节。

例如一个 32 层、32 个 KV Head、Head Dimension 为 128 的模型,在 Batch Size 为 1、上下文为 8192、FP16 存储时,理论 KV Cache 约为:

<div>

$2\times32\times1\times8192\times32\times128\times2 \approx 4\ \mathrm{GiB}.$

</div>

但这不是所有 7B 模型的固定答案。采用 GQA 或 MQA 后,$H_{\mathrm{KV}}$ 会远小于 Query Head 数,容量可明显下降;PagedAttention、块分配、前缀复用、量化 KV Cache、滑动窗口注意力和 Tensor Parallel 也会改变物理占用。生产估算还要加入 Block 内部碎片、运行时元数据、Beam Search 复制、并发请求峰值及安全余量。

KV Cache 的主要代价包括显存线性增长、内存带宽压力、长上下文下的碎片与调度复杂度,以及多并发场景中更低的可容纳 Batch。面试时可以用一句话收束:KV Cache 省掉的是历史状态的重复计算,但没有让历史上下文本身消失,长上下文推理最终会从算力瓶颈逐步转向显存容量和带宽瓶颈。

2. Agent上下文超限了怎么处理?

面试官: Agent执行多步任务时,对话历史加工具返回结果很容易超限,你有什么处理方案?

面试官追问: 三种策略分别在什么场景下用?

回答:

Agent 上下文治理的核心不是“把旧消息删掉”,而是在有限 Token 预算内,持续保留完成当前任务所必需的状态、证据和约束。一个成熟方案通常分为五层。

第一层是预算与优先级管理。先为 System Instruction、工具 Schema、当前用户请求、近期对话、任务状态、检索证据和输出预留独立预算。System 约束、权限边界和当前目标不能被普通历史消息挤出上下文。

第二层是近期窗口。保留最近若干轮原始消息,适合强依赖局部指代、短期状态快速变化的实时对话。但窗口不应只按“轮数”截断,而应按 Token、消息类型和任务依赖截断。机械丢弃最早消息可能删掉长期有效的用户约束。

第三层是结构化状态与摘要。把历史对话压缩成任务状态,例如目标、已确认事实、未完成子任务、约束、决策及证据引用。摘要应支持版本号、更新时间和可追溯来源;发生冲突时,新事实不能静默覆盖旧事实,而应记录“旧值、当前值、变更依据”。

第四层是外部记忆检索。把长期历史、文档和经验存入可检索存储,按语义、关键词、时间、实体、会话和权限联合召回。向量相似度只表示“像”,不表示“当前有效”,因此还要考虑新鲜度、来源可信度、用户归属和业务权限。

第五层是工具结果压缩。Agent 最容易被日志、网页全文、数据库大结果集和代码构建输出撑爆。应在工具层先做分页、字段投影、去重、统计聚合、错误摘要和 Artifact 引用,只把决策所需片段送回模型,而不是把原始输出全部塞回上下文。

场景选择可以这样回答:

  • 短对话和低风险客服:近期窗口加少量结构化槽位即可。
  • 长周期任务:近期窗口、任务状态、阶段摘要和 Artifact 引用组合使用。
  • 跨会话个性化:使用权限隔离的长期记忆检索,并设置过期、确认和删除机制。
  • 知识问答:重点是检索外部证据,而不是把所有历史对话永久保留。
  • 高风险操作:关键参数、用户确认、审批结果和工具回执必须保留为不可丢失的结构化状态。

摘要模型不一定越小越好。小模型成本低,但数字、否定关系、权限条件和未完成事项一旦压错,后续所有步骤都会在错误状态上继续。应使用专门评测集比较事实保真率、未完成任务召回率、压缩率和下游成功率,再决定模型与策略。上下文压缩的目标不是最短,而是让 Agent 在更少 Token 中仍然保持任务连续性、事实一致性和可恢复性。

3. Agent取消机制怎么做?

面试官: 一个Agent任务耗时很长,用户等不及了,怎样停止这个任务?这个机制你实际尝试过吗?

回答:

Agent 取消机制的本质是可传播的协作式取消 + 资源级强制终止 + 状态一致性恢复。只在主循环里放一个布尔标记,只能停止下一步调度,不能保证当前网络请求、子进程、数据库事务或外部工具真正停下来。

完整设计可以分为六层:

  1. 任务状态机。 每个任务具有 task_id、RUNNING/CANCELLING/CANCELLED/SUCCEEDED/FAILED 状态、截止时间和父子任务关系。取消请求必须幂等,多次取消返回同一个最终状态。
  2. 取消令牌传播。 从 HTTP 请求或交互层生成 Cancellation Token,向 Planner、Agent Loop、模型流式请求、工具调用和子任务逐层传播。每个耗时阶段都在安全点检查,而不是只在循环开头检查。
  3. Deadline 与超时。 将剩余时间预算传给下游,区分连接超时、首 Token 超时、空闲读取超时和总耗时。超时本质上是由系统发起的取消,也必须走同一条清理链路。
  4. 底层中止。 对支持取消的 HTTP 客户端主动关闭请求或流;对子进程先发送优雅终止信号,超时后再强制终止进程组;对容器或沙箱可以直接回收执行单元。Future.cancel() 或 Thread.interrupt() 只是发出信号,目标代码不响应时并不等于任务已经停止。
  5. 副作用治理。 已执行的发邮件、扣款、写文件等动作不能靠取消“回滚”。工具应支持幂等键、事务、预提交、补偿动作和操作日志。取消时停止未来动作,并对已提交副作用执行明确的补偿或人工确认。
  6. 资源清理与观测。 在 finally、whenComplete 或上下文管理器中释放连接、文件句柄、锁、临时目录和 GPU Session;记录取消来源、传播延迟、未能中止的子任务和最终一致性结果。

Java 中 CompletableFuture.orTimeout() 只会让 Future 以超时异常完成,通常不会自动停止已经运行的底层任务;丢弃 Future 更不会释放连接。正确做法是让执行函数显式接收取消令牌或 Deadline,并使用能够中止请求的客户端 API。对于不可协作的第三方 SDK,应把调用隔离到独立进程、容器或受控 Worker,通过终止执行单元形成最后一道边界。

实际项目回答应给出可验证证据,例如“取消请求 P95 多久生效”“正在进行的工具是否真正停止”“已产生副作用如何恢复”,而不是只说自己使用了某个并发类。面试式结论是:取消不是线程 API 问题,而是从用户请求一直传播到资源和副作用的一条系统契约。

4. MCP是什么?Agent通过MCP调用工具的流程是什么?

面试官: MCP是什么?Agent通过MCP调用工具的流程是什么?

面试官追问: 你觉得MCP能成为标准吗?

回答:

MCP,即 Model Context Protocol,是一种面向 AI 应用与外部能力之间连接的开放协议。它解决的不是模型“会不会决定调用工具”,而是 Host、Client 与 Server 之间如何发现能力、协商版本、传输请求、返回内容并管理连接生命周期。Tools、Resources、Prompts 是常见的 Server 能力;较新的协议还在能力协商下支持日志、Sampling、Elicitation、Tasks 等扩展。

更准确的角色关系是:

  • Host 是承载 Agent 的应用,例如 IDE、桌面客户端或业务服务,负责权限、上下文、模型调用和用户交互。
  • MCP Client 运行在 Host 内部,通常与一个 MCP Server 建立一个逻辑连接。
  • MCP Server 暴露工具、资源或提示模板,并连接本地文件、数据库、SaaS 或企业系统。

典型调用流程如下:

  1. Client 通过 initialize 与 Server 协议握手,协商协议版本和双方能力,然后发送 initialized 通知。
  2. Host 根据需要通过 tools/list 等方法发现能力,将经过权限过滤的工具 Schema 提供给模型。
  3. 模型基于用户请求生成结构化工具调用意图。注意,这一步通常属于模型 API 或 Agent Runtime,而不是 MCP 协议替模型做决策。
  4. Host 校验工具名、参数 Schema、用户权限、风险级别和是否需要人工批准。
  5. Client 通过 JSON-RPC 2.0 向 Server 发起 tools/call。本地常见传输是 stdio,远程常见传输是 Streamable HTTP。
  6. Server 执行能力并返回结构化内容、错误状态或资源引用。Host 对结果做大小限制、脱敏、可信度标记和 Prompt Injection 防护。
  7. Host 将工具结果关联到原始调用,再交回模型继续推理,直到产生最终回答或触发下一次工具调用。

关于“能否成为标准”,到当前阶段已经不能再回答“OpenAI 和 Google 是否跟进还不确定”。OpenAI Agents SDK 与 Google ADK 均已提供 MCP 集成,主流开发工具和 Agent 生态也在广泛接入。更合理的判断是:MCP 已经成为 Agent 工具与上下文连接层最有影响力的开放协议之一,但“广泛采用”不等于所有系统都必须统一成一个运行时。

它仍有三类边界:第一,协议统一接口,不统一工具语义、数据质量和业务事务;第二,远程 Server 带来授权、Token 转发、供应链和 Prompt Injection 风险;第三,低延迟、强事务、超高吞吐的内部调用仍可能直接使用 gRPC、HTTP 或消息系统。MCP 的长期价值更像 Agent 世界的连接协议,而不是替代所有服务协议。

5. RAG中的父子索引是什么?解决了什么问题?

面试官: RAG为什么需要设计父子索引?检索小文本块、返回大父文档能解决什么问题?

面试官追问: 这样设计有什么代价?

回答:

父子索引解决的是 RAG 中一个非常核心的矛盾:用于匹配的文本块希望足够小,用于回答的上下文又希望足够完整。

如果 Chunk 太大,一个向量同时混入多个主题,查询信号容易被稀释,精确术语也可能匹配不出来;如果 Chunk 太小,虽然局部语义更纯,但标题、定义、前提、表格上下文和跨段逻辑会被切断。父子索引把这两个粒度拆开:

  1. 以较小的子块建立向量或关键词索引,每个子块记录 parent_id、位置、版本和权限元数据。
  2. 查询时检索子块,可先混合 BM25 与向量召回,再做 Rerank。
  3. 将命中的子块映射回父块或相邻窗口,按父文档去重、合并和控制总 Token。
  4. 把更完整的父级上下文交给生成模型,并保留子块命中位置用于引用和可解释性。

这里的“父”不一定是整篇文档,也可以是一个章节、一个表格加说明、一个函数或一个带标题的语义段落。父块过大仍会引入噪声,因此父子尺寸不能只凭经验固定为 256/1024 Token,应根据文档结构、查询类型和下游评测调优。

它的代价主要包括:

  • 索引与文档存储更复杂,需要维护子块到父块的映射、版本和权限一致性。
  • 多个命中子块可能指向同一个父块,若不去重会重复占用上下文。
  • 返回父块会放大 Token 和延迟,甚至把未授权的兄弟内容一起带出,因此权限应至少落实到可返回的父级粒度。
  • 子块的 Rerank 分数不能直接等同于父块分数,需要设计 Max、Top-N 聚合或覆盖度评分。
  • 文档更新后,父块与子索引必须原子换版,否则会出现旧索引指向新正文或反过来的不一致。

验证时应比较 Recall@K、MRR/nDCG、答案正确率、引用命中率、上下文利用率、Token 成本和延迟,并对普通小块、滑动窗口、父子索引和层级检索做消融。父子索引不是简单多存一份文本,而是把“在哪里命中”和“带什么上下文回答”拆成两个可分别优化的阶段。

6. ReAct模式和Plan模式有什么区别?

面试官: ReAct模式和Plan模式有什么区别?分别适合什么场景?

回答:

ReAct 与 Plan 模式的本质区别,是“决策与执行交错进行”还是“先形成显式计划,再由执行器推进”。二者不是互斥框架,也不能简单等同于“ReAct 灵活但慢、Plan 一定高效”。

ReAct 通常按 Observation -> Reason/Decision -> Action -> New Observation 循环。每一步都根据最新环境反馈决定下一步,适合信息不完整、需要搜索、工具结果不确定或错误恢复频繁的任务。优势是反馈闭环短;缺点是模型可能局部贪心、重复调用、路径漂移,而且每一步都要重新推理,成本和延迟较高。

Plan-and-Execute 先把目标拆成子任务、依赖和验收条件,再由执行器逐步完成。计划可以是线性列表,也可以是 DAG,但“Plan 模式必然一次性生成完整 DAG”并不准确。优势是全局目标更清晰、任务可并行、便于预算和审计;缺点是环境变化后原计划会过时,错误计划如果不允许重规划,执行越认真可能偏得越远。

场景上可以这样选择:

  • 开放式检索、故障排查、网页操作、代码探索:更适合 ReAct,因为下一步依赖刚获得的观察。
  • 固定审批流、批处理、可预测的数据流水线:更适合显式 Plan 或 Workflow。
  • 长周期复杂任务:通常采用分层混合模式,Planner 生成里程碑和依赖,每个子任务内部使用 ReAct,遇到关键假设失效时触发 Replan。

工程实现中,计划不应只是一段自然语言。至少要有 step_id、依赖、输入、工具权限、预算、完成条件和状态;执行器则要维护最大步数、重试策略、幂等键、Checkpoint 和取消信号。每一步完成后由确定性 Verifier 或环境状态判断是否达标,不能只让同一个模型口头宣布成功。

一个成熟系统还要支持“不要用 Agent”的判断。如果流程稳定、风险高且规则明确,状态机或工作流引擎通常比自主 ReAct 更可靠。ReAct 解决动态探索,Plan 解决全局分解,生产系统真正重要的是反馈、验证和重规划,而不是给循环贴哪一个模式标签。

7. 工具返回结果的消息结构怎么设计?为什么需要单独的Tool Message?

面试官: Agent调用工具后,工具返回结果的消息结构怎么设计?为什么需要单独的Tool Message?

回答:

Tool Message 的核心作用,是把“模型提出的工具调用”和“外部系统返回的观察结果”建立明确、可机器验证的关联。它不是普通用户消息,也不是系统指令。如果把工具输出拼成用户文本,模型、编排器和审计系统都很难判断这段内容来自谁、对应哪次调用、是否执行成功,以及能否被信任。

一个通用的工具结果结构可以包含:

{
  "role": "tool",
  "tool_call_id": "call_123",
  "name": "query_order",
  "status": "success",
  "content": [{"type": "text", "text": "..."}],
  "error": null,
  "metadata": {
    "latency_ms": 83,
    "attempt": 1,
    "truncated": false,
    "artifact_id": null
  }
}

具体字段要服从所使用的模型 API 或协议,不能假设所有厂商都接受自定义 status 和 metadata。通常必须保证:tool_call_id 与模型发出的调用一一对应;结果内容类型明确;业务失败与协议失败可区分;大结果通过 Artifact、分页或引用返回;敏感字段在进入模型前完成脱敏。

单独设计 Tool Message 有五个价值:

  1. 关联性。 并行调用多个工具时,可把每个结果精确绑定到原始调用。
  2. 角色边界。 模型知道这是一条外部观察,而不是用户的新指令。
  3. 可恢复性。 编排器可以根据超时、可重试错误、参数错误或权限错误采用不同策略。
  4. 可观测性。 调用链能够记录工具、参数摘要、延迟、重试和结果大小。
  5. 安全性。 工具内容可被标记为不可信数据,避免网页或文档中的恶意文本越权成为高优先级指令。

需要特别强调:Tool Message 虽然角色独立,但并不会自动消除 Prompt Injection。网页、邮件和数据库文本仍可能包含“忽略之前指令”之类内容。Host 必须限制工具权限、隔离数据与指令、对高风险动作二次验证,并避免把原始 HTML、系统密钥或超长日志直接交给模型。

Tool Message 的本质不是多一个 role 字段,而是把工具调用从自然语言聊天提升为可关联、可审计、可恢复的执行协议。

8. 多个Agent同时修改同一个文件,如何避免冲突?

面试官: 多个Agent同时修改同一个文件,如何避免冲突?

回答:

最优先的原则不是“给同一个文件加一把更大的锁”,而是尽量减少共享可变状态。可以先按模块、目录、函数或任务所有权拆分,让多个 Agent 在独立 Workspace、分支或 Git Worktree 中工作,最后通过 Patch 合并。只有确实需要修改同一逻辑区域时,才进入冲突控制。

一种可靠的工程链路是:

  1. 每个 Agent 从同一个基线提交或文件哈希开始,在隔离工作区产生结构化 Patch。
  2. 提交前执行 Compare-And-Swap:确认目标文件当前哈希仍等于读取时的基线哈希。
  3. 若基线未变化,原子应用 Patch;若已变化,使用三方合并,即 Base、Current、Agent Result,而不是覆盖写入。
  4. 只在“检查版本 + 应用 Patch + 更新版本”的短临界区加锁,避免把模型思考和工具执行时间放进锁内。
  5. 合并后运行解析器、格式化、类型检查、测试和领域不变量校验。文本无冲突不代表语义无冲突。

乐观并发控制适合冲突概率低、Agent 任务较长的场景;悲观锁适合短事务和高冲突热点,但必须设置租约、持有者 Token、超时和 Fencing Token,避免锁过期后旧持有者继续写入。仅使用 Redis SET NX PX 而没有 Fencing,无法完全防止暂停进程恢复后的陈旧写。

对于代码仓库,Git 的 Patch 和三方合并通常优于直接共享文件锁;对于结构化文档,可以按 AST、JSON Patch 或数据库行级版本合并。所谓“让 LLM 仲裁冲突”只能作为生成候选合并结果,最终仍要由确定性校验和测试验证。

还要处理原子落盘:先写临时文件、fsync,再原子重命名,避免进程崩溃留下半个文件。写操作应带 operation_id,重试时保持幂等。

多 Agent 协同的关键是隔离、版本、Patch、短临界区和验证闭环;锁只是其中一个工具,不能替代语义合并。

9. Agent陷入死循环或重复调用,怎么兜底?

面试官: Agent陷入死循环或重复调用同一个工具,你们的系统有什么兜底机制?

回答:

死循环治理要同时限制资源消耗和识别“没有进展”。固定最大 15 步、同一工具每分钟 5 次可以作为某个业务的配置,但不能当成普适答案。搜索任务、代码任务和高风险交易的合理预算完全不同。

可以设计六层保护:

  1. 硬预算。 限制总步骤、模型 Token、工具次数、总耗时、金额、外部请求数和并行度。任何一个预算耗尽都进入受控终止,而不是继续请求模型“再试一次”。
  2. 调用指纹。 对 tool_name + 规范化参数 + 关键环境版本 做哈希。连续重复相同调用且环境未变化时,不再次执行副作用工具,而是返回缓存结果或明确告知重复。
  3. 进展不变量。 观察已解决子目标数、错误类型、文件哈希、数据库状态、测试通过数或检索到的新证据。若若干步后状态没有增量,即使工具参数略有变化,也可能是循环。
  4. 错误分类与重试策略。 仅对瞬时错误重试,并采用指数退避和随机抖动;参数错误、权限错误和确定性业务拒绝不应盲目重试。
  5. 熔断与降级。 某工具持续失败时打开 Circuit Breaker,切换备用工具、回退到只读模式、请求用户补充信息或转人工。
  6. 可恢复终止。 退出前保存 Checkpoint、已完成事项、失败原因和下一步建议,使用户或人工能够从断点继续。

不建议依赖“最近几轮 Thought 语义相似度”作为主要检测手段。一方面模型的隐式推理未必可见或适合持久化,另一方面语言表述可以变化但行为仍然重复。应优先基于可观察的 Action、参数、环境状态和任务进度检测。

副作用工具还必须使用幂等键和执行前置条件。即使 Agent 重复调用,支付、发信、发布和删除操作也不能重复生效。高风险动作应采用准备、确认、提交的两阶段交互,并在提交前再次校验用户授权和当前状态。

真正的兜底不是“发现循环后报错”,而是让系统在任何重复、超时和失败路径下都能控制成本、阻止重复副作用,并留下可恢复状态。

10. 多Agent之间通信方式是什么?如何做任务分发和结果合并?

面试官: 多Agent之间怎么通信?如何做任务分发和结果合并?

回答:

多 Agent 通信首先要选择协作拓扑,而不是先选择 Redis 还是 gRPC。常见结构包括 Supervisor-Worker、Planner-Executor、黑板系统、事件驱动协作和点对点会话。生产中通常优先采用中心编排或共享任务状态,因为它更容易控制权限、预算、重试和审计。

通信方式可按可靠性需求选择:

  • 同进程低延迟协作:内存队列、Channel、asyncio.Queue,但进程退出后状态会丢失。
  • 同集群同步调用:gRPC/HTTP,适合短请求和明确响应,不适合把长任务可靠性全部压在连接上。
  • 异步可靠任务:Kafka、RabbitMQ、Redis Streams 或云消息队列,支持确认、重试、消费组和死信。Redis Pub/Sub 不保存消息,消费者离线时会丢失,不能直接作为关键任务总线。
  • 长周期工作流:使用持久化工作流引擎或事件溯源状态机,支持 Checkpoint、补偿和恢复。

任务分发时,Planner 应输出结构化任务:task_id、父任务、依赖、输入 Artifact、能力标签、优先级、Deadline、预算、权限和验收条件。Router 根据 Agent 能力、负载、数据位置、风险等级和历史质量调度。Worker 通过租约领取任务,使用幂等键执行,并通过 Heartbeat 防止任务永久卡死;租约到期后可以重派,但必须用 Fencing Token 防止旧 Worker 迟到写入。

结果合并不能只依赖“多数投票”。多个 Agent 可能共享同一模型和同一错误,因此错误高度相关。更可靠的方法是按任务类型选择 Verifier:代码用测试和静态分析,检索用证据覆盖与引用,计算用程序复算,业务操作用环境状态和事务回执。对可拼接结果按 Schema 合并;对互斥结论保留证据、置信度和冲突原因,再由独立 Judge 或人工决策。

所有消息应至少带有 trace_id、task_id、attempt、schema_version、producer、时间、幂等键和 Artifact 引用,并限制 Agent 只能访问完成任务所需的工具与数据。

多 Agent 系统的价值不在于“Agent 数量更多”,而在于能否把任务分解、隔离执行、可靠传递和可验证合并做成一条闭环。

11. 上下文压缩保留什么信息?如何判断哪些内容可以丢弃?

面试官: 上下文压缩时保留什么信息?怎么判断哪些内容可以丢弃?

追问: 压缩任务交给小模型还是大模型?

回答:

上下文压缩应围绕“未来决策还依赖什么”来做,而不是围绕“哪些句子看起来重要”。建议保留以下几类信息:

  1. 当前目标与完成标准。 用户到底要什么,什么状态才算完成。
  2. 硬约束与授权。 时间、预算、格式、禁止项、权限范围、人工确认和安全规则。
  3. 任务状态。 已完成步骤、未完成步骤、阻塞原因、重试次数和下一步。
  4. 关键事实与实体。 明确确认的人、对象、数字、版本、路径、时间和业务状态。
  5. 决策及依据。 为什么选择某方案,哪些方案被排除,依据来自哪个证据或工具回执。
  6. 可恢复引用。 大型工具结果不必全文保留,但要保留 Artifact ID、查询条件、版本、摘要和关键片段。
  7. 用户稳定偏好。 只保留经用户确认、与后续任务相关且在隐私策略允许范围内的偏好。

通常可以压缩或丢弃的是:已被确定性结果取代的探索过程、重复日志、无新增信息的寒暄、已成功消费的大型中间输出,以及不影响后续行为的语言修辞。但不能简单删除所有“中间推理”。系统不需要保存模型私有 Chain-of-Thought,却需要保存可审计的决策摘要、证据和行动结果。

判断是否可丢弃,可以对每条信息做四个问题:后续步骤是否直接依赖它;删除后能否从外部 Artifact 可靠恢复;它是否属于安全或合规证据;它是否已被更新且旧值只需进入变更历史。压缩后应执行一致性校验,例如实体、数字、否定关系、未完成任务和权限条件是否仍然完整。

小模型还是大模型没有统一答案。结构化抽取、去重和日志归并可由规则或小模型完成;跨文档冲突、复杂计划和高风险事实压缩需要更强模型或双模型复核。选择标准应是压缩成本与下游错误成本,而不是模型价格本身。可以用事实保真率、约束召回率、未完成任务召回率、压缩率和最终任务成功率做评测。

优秀的上下文压缩不是写一段更短的摘要,而是构造一个可追溯、可更新、足以驱动下一步行动的最小任务状态。

12. LLM调用失败怎么排查?

面试官: 调用大模型API失败,你的排查流程是什么?

回答:

排查 LLM 调用失败应先回答三个问题:请求是否到达服务端,服务端是否开始生成,失败是否可以安全重试。推荐按调用链分层,而不是一上来只看 CPU 和内存。

  1. 确定影响范围。 是单请求、单模型、单地域、单租户还是全量失败;记录开始时间、错误率、P95/P99 延迟和最近变更。
  2. 建立关联。 用 trace_id/request_id 串起网关、应用、模型服务和工具日志。记录模型、版本、区域、流式与否、输入/输出 Token、参数摘要和 HTTP 状态,但不得记录密钥或未脱敏的敏感 Prompt。
  3. 定位网络阶段。 分别检查 DNS、TCP、TLS、代理、连接池、连接超时、首字节/首 Token 超时、流式空闲超时和总 Deadline。能够建连不代表流式响应没有中途断开。
  4. 按状态码分类。 400 多为参数、Schema 或上下文问题;401/403 是认证、权限或项目配置;404 可能是模型名或 Endpoint;409 常见于状态冲突;429 是速率或配额限制;5xx 表示服务端、上游依赖或容量异常。仍应以具体供应商错误码为准。
  5. 检查请求内容。 计算真实 Token,验证模型上下文限制、Tool Schema、JSON 格式、图片大小、停止条件和内容安全策略。不要只按字符数猜 Token。
  6. 最小化复现。 使用相同模型和认证发送最小请求,再逐项恢复参数、工具和上下文,找出触发差异。对比最近成功样本与失败样本,但先做脱敏。
  7. 检查服务端资源。 若是自部署模型,再看队列长度、Batch、GPU OOM、KV Cache、显存碎片、推理引擎日志、模型权重、NCCL 和节点健康。

重试必须服从错误语义。连接重置、部分 429/5xx 可采用指数退避、随机抖动和 Retry-After;参数错误与权限错误不应重试。对有副作用的工具链要带幂等键,避免模型请求重试导致动作重复。流式调用中断后,不能默认把半截输出当成完整答案,应标记不完整并决定续写、重放或降级。

生产系统还需要熔断、备用模型、区域切换、队列削峰、请求限流和质量降级,并监控成功率、首 Token 延迟、生成吞吐、Token 使用、取消率和降级率。排障的关键是按层定位并保留请求关联,而不是把所有失败都归因于“网络波动”。

13. 分布式唯一ID怎么生成?要求自增怎么办?

面试官: 多台机器如何生成唯一ID?如果要求自增呢?

回答:

先要澄清“自增”的语义,因为唯一、趋势递增、单调递增、严格连续和按真实时间排序是不同要求,成本也完全不同。

  • 只要求全局唯一:UUIDv4、随机 128 位 ID 等最简单,但索引局部性较差。
  • 要求大致按时间排序:UUIDv7、ULID、Snowflake 类 ID 更适合数据库索引和日志排序。
  • 要求每个业务分区单调递增:可以为租户、订单域或分片分别维护 Sequence。
  • 要求全局严格递增:必须经过某个线性化的排序点,吞吐和可用性会受到约束。
  • 要求无空洞连续:分布式系统中代价最高,事务回滚、预取和故障都会造成 Gap,通常不应把它作为普通主键需求。

Snowflake 一般由时间戳、节点 ID 和毫秒内序列组成,吞吐高、趋势递增且无需每次访问中心服务。风险包括时钟回拨、节点 ID 冲突、同毫秒序列耗尽和时间位溢出,因此需要时钟监控、回拨等待或逻辑时钟、节点租约与唯一分配。

如果要求全局严格递增,可以使用数据库 Sequence/自增主键、Redis INCR、具备共识的 ID 服务或单 Leader 排序服务。但这些方案都把生成过程集中到一个线性化点,需要高可用、持久化、故障切换和热点治理。Redis INCR 在单主正常工作时原子递增,但要结合持久化和故障切换语义评估是否可能回退或重复。

Leaf Segment 一类号段模式让应用批量领取区间,再在本地递增,显著降低中心压力;代价是趋势递增而非严格按请求时间排序,实例故障还会浪费未使用号段。多数据库设置不同起点和步长能保证不重复,却不能保证按生成时间严格单调,更不能保证无空洞。

数据库主键还要关注索引写入。完全随机 UUID 会让 B+ 树频繁页分裂;时间有序 ID 更利于追加写,但如果业务暴露连续 ID,又可能泄漏订单量并便于枚举,因此外部标识可与内部主键分离。

面试时最好的回答不是背三个算法,而是先把唯一性、顺序性、连续性、吞吐、可用性和信息泄漏要求拆开,再选择最低成本的满足方案。

14. 缓存击穿、穿透、雪崩是什么?怎么解决?

面试官: 缓存击穿、缓存穿透、缓存雪崩是什么?怎么解决?

回答:

三者的共同结果都是后端承压,但触发机制不同。

缓存穿透是请求的数据本来就不存在,缓存和数据库每次都 Miss。常见来源是恶意枚举、参数错误或脏数据。治理手段包括参数校验、鉴权与限流、缓存短 TTL 的空值、布隆过滤器或 Cuckoo Filter。过滤器存在误判且需要维护新增/删除一致性,不能替代数据库校验;空值缓存也要防止占满缓存。

缓存击穿是某个高热 Key 在失效或回源期间,大量并发同时访问数据库。常用方案有 SingleFlight/互斥回源、逻辑过期加异步刷新、提前刷新、热点 Key 永不过期但由版本切换更新,以及本地缓存兜底。互斥锁必须有超时、持有者标识和异常释放;等待线程要有 Deadline,不能无限阻塞。

缓存雪崩是大量 Key 在相近时间失效,或整个缓存集群、网络链路同时不可用,导致大规模流量回源。解决方案包括 TTL 随机抖动、分批预热、缓存高可用与分片、本地多级缓存、限流、熔断、请求排队和后端容量保护。需要注意,随机 TTL 只能缓解集中失效,不能解决 Redis 整体故障。

成熟系统还应处理缓存与数据库一致性。常见 Cache-Aside 流程是读缓存 Miss 后查库并回填,写操作先更新数据库再删除缓存,并通过消息、Binlog 或延迟双删处理失败窗口。对强一致业务,不能只靠“设置一个更短 TTL”;可以使用版本号、事务消息或直接绕开缓存。

压测时应模拟热点过期、随机不存在 Key、缓存节点宕机、网络抖动和回源慢查询,并观察数据库 QPS、连接池、缓存命中率、回源并发和恢复时间。穿透是不存在数据反复穿过,击穿是单个热点被打穿,雪崩是大面积缓存同时失守;治理重点分别是拦截无效请求、合并热点回源和保护整个后端。

15. Redis为什么能支撑高并发?

面试官: Redis为什么能支撑很高并发?

追问: Redis 6.0之后加了多线程IO,还能叫单线程吗?

回答:

Redis 高并发不是因为某一个“单线程黑科技”,而是数据路径短、事件模型简单、数据结构高效和批量化能力共同作用的结果。

首先,大多数访问在内存完成,避免了磁盘随机 I/O;但不能说网络请求是“纳秒级”,真实延迟还包含网络、协议解析、排队、持久化和主从复制,通常应以微秒到毫秒级服务指标衡量。

其次,Redis 使用事件循环和 I/O 多路复用管理大量连接,避免为每个连接创建一个阻塞线程。核心命令执行长期以串行语义为主,因此单条命令天然具有原子性,也减少了共享数据结构上的锁竞争和上下文切换。但耗时 $O(N)$ 命令、Lua 脚本或大 Key 仍会阻塞后续请求,所以“单线程”不等于任何场景都快。

再次,Redis 针对数据形态选择紧凑结构,例如 SDS、字典、Intset、Listpack、Quicklist 和 Skiplist,并结合渐进式 Rehash、过期策略和内存分配优化。Pipeline、MGET/MSET、连接复用和批量命令能够摊薄网络往返成本;集群分片则把压力扩展到多个节点。

Redis 6.0 之后引入多线程网络 I/O,后续版本也持续演进并行能力。因此现在更准确的说法是:Redis 不是一个所有工作都由单线程完成的程序;它可以使用后台线程和 I/O 线程,但大量核心命令仍保持每个分片内的串行执行语义。 网络读取、协议处理和写回可以并行,命令对核心键空间的执行模型仍强调确定性和低锁开销。

工程上还要关注高并发下的反例:大 Key 删除、全量扫描、复杂集合运算、Fork 抖动、AOF fsync、主从全量同步、热点 Key 和客户端输出缓冲区都可能成为瓶颈。优化顺序通常是先修正命令复杂度和数据模型,再用 Pipeline 和连接池降低 RTT,随后做热点拆分、读写分离或 Cluster 扩容。

Redis 能扛高并发的本质,是让绝大多数命令沿着短而可预测的数据路径执行;一旦命令复杂度、数据体量或持久化路径变得不可预测,单线程语义反而会放大尾延迟。

16. ThreadLocal为什么可能内存泄漏?

面试官: ThreadLocal为什么可能内存泄漏?

追问: 怎么避免?

回答:

每个 Java Thread 内部都可以持有一个 ThreadLocalMap。Map 中的 Entry 对 ThreadLocal Key 使用弱引用,但对 Value 使用强引用。当业务代码不再持有某个 ThreadLocal 时,Key 可能被 GC 回收并变成 null,而 Value 仍然被 Thread -> ThreadLocalMap -> Entry -> Value 这条强引用链持有。

如果线程很快结束,整个 Thread 和 Map 都可回收,问题不明显;在线程池、Web 容器和应用服务器中,Worker 线程长期存活,陈旧 Value 就可能长时间滞留,形成内存泄漏或至少是生命周期远超预期的内存占用。还要注意 ThreadLocalMap 是开放寻址的数组结构,不是普通的 Entry 链表。Java 会在 get/set/remove 等操作中机会性清理 Stale Entry,但不保证某个不再访问的槽位立即被清除。

标准写法是:

private static final ThreadLocal<Context> CONTEXT = new ThreadLocal<>();

try {
    CONTEXT.set(context);
    handleRequest();
} finally {
    CONTEXT.remove();
}

避免问题需要做到:

  1. 每次 set() 后在 finally 中 remove(),即使异常或取消也要执行。
  2. 在线程池任务边界统一安装清理装饰器,避免业务开发者漏清理。
  3. 不在 ThreadLocal 中长期保存大对象、连接、ClassLoader、请求/用户对象或安全凭证。
  4. 在线程复用时防止上下文串号。即使没有 OOM,前一个请求的用户身份泄漏给后一个请求也是严重问题。
  5. 异步切线程时不要假设 ThreadLocal 自动传播。应显式传递 Context,或使用框架提供的受控上下文传播机制。

static final ThreadLocal 只能让 Key 自身长期存在,减少 Key 被提前回收的情况,却不能替代 remove();Value 仍会在线程之间残留。InheritableThreadLocal 在线程池中更容易产生错误继承,也要谨慎使用。

ThreadLocal 泄漏的根因不是弱引用本身,而是 Key 与 Value 生命周期不对称,再叠加长期存活线程;解决方案是把清理放到任务生命周期边界。

17. BeanFactory和ApplicationContext有什么差异?

面试官: BeanFactory和ApplicationContext有什么差异?

回答:

BeanFactory 是 Spring IoC 容器最基础的能力契约,负责 Bean 的注册、查找、依赖注入、作用域和生命周期协作。ApplicationContext 建立在更完整的 BeanFactory 体系之上,面向实际应用提供统一的运行时上下文。

二者的差异可以从四层理解:

  1. 容器能力。 ApplicationContext 同时具备可列举和分层 BeanFactory 能力,并集成资源加载、环境与 Profile、国际化消息、应用事件发布等接口。
  2. 启动流程。 常见 ApplicationContext 在 refresh() 时会自动发现并执行 BeanFactoryPostProcessor、注册 BeanPostProcessor,并默认预实例化非 Lazy 的 Singleton。裸 BeanFactory 通常需要调用者自行完成更多装配步骤。
  3. 基础设施集成。 AOP 自动代理、事务、注解处理、事件、组件扫描等能力通常由 Context 启动流程中的后处理器和基础设施 Bean 完整启用。
  4. 使用场景。 普通 Spring/Spring Boot 应用几乎都使用 ApplicationContext;BeanFactory 更常作为底层 SPI、轻量测试或框架内部扩展点。

“BeanFactory 懒加载、ApplicationContext 饿加载”只是默认行为的简化说法,不是接口层面的绝对差异。Bean 是否延迟创建取决于 Bean 定义、作用域、Context 刷新策略和是否提前被依赖。ApplicationContext 也支持 @Lazy,BeanFactory 也可以主动预实例化 Singleton。

还应注意,不要把 ApplicationContext 理解成另一个与 BeanFactory 平行的容器。它仍以 BeanFactory 作为核心注册表和实例工厂,只是在其上增加应用级生命周期与服务。Spring Boot 又进一步负责创建和配置合适的 ApplicationContext,并加载自动配置。

一句话总结:BeanFactory 定义“Bean 怎么被管理”,ApplicationContext 定义“一个 Spring 应用如何在完整环境中启动、扩展和协作”。

18. 配置中心宕机了应用怎么不挂?

面试官: 如果配置中心宕机或连不上,怎样保证应用不挂?

回答:

配置中心容灾不能统一采用“失败就读本地默认值”。配置分为普通业务参数、动态开关、服务发现信息、密钥和安全策略,不同配置的 Fail-Open/Fail-Closed 策略应不同。旧的推荐排序可以继续使用,过期证书、撤销名单或权限策略却可能必须拒绝启动或进入只读模式。

完整方案包括:

  1. Last Known Good Snapshot。 客户端把最近一次校验通过的完整配置持久化到本地,包含版本、命名空间、时间、校验和或签名。启动连接失败时,只加载兼容且未超出允许陈旧时间的快照。
  2. 分级启动策略。 非关键配置可使用默认值或快照;关键安全配置缺失时 Fail-Fast;部分业务可进入只读或降级模式,避免带着未知权限继续写操作。
  3. 内存继续服务。 运行期间配置中心失联,已加载配置继续生效。后台以指数退避和随机抖动重连,避免配置中心恢复时所有实例同时重试形成惊群。
  4. 原子更新。 拉取新配置后先做 Schema、类型、范围、依赖和签名校验,再整体切换版本。不能逐字段更新,让请求读到半新半旧状态。
  5. 版本与回滚。 保留多个可用版本,若新配置导致错误率或启动失败,能够自动回退。配置变更要有审计、灰度和权限控制。
  6. 服务端高可用。 多节点、多可用区、持久化存储、客户端地址列表和健康检查,不能把全部可靠性寄托在客户端快照上。

本地 application.yml 可以提供非敏感的最小默认值,但不应硬编码生产密钥,也不能让默认值静默绕过安全限制。快照本身要设置文件权限、加密或签名校验,防止本地篡改。

验证时要做故障演练:冷启动时中心不可用、运行中断联、新配置损坏、版本回退、中心恢复后大规模重连,以及快照过期。观察应用是否启动、功能是否降级、配置是否一致和是否产生越权。

配置中心容灾的核心不是“永远不挂”,而是为每类配置明确可接受的陈旧程度和失败模式,让系统在依赖失效时仍保持可解释、可审计的行为。

19. TCP一定需要四次挥手吗?

面试官: TCP为什么有四次挥手?一定需要四次挥手吗?

回答:

TCP 是全双工协议,两个方向的字节流可以独立关闭。主动关闭方发送 FIN,只表示“我没有更多数据要发送”,并不表示对方也发送完了。被动关闭方先 ACK 这个 FIN,应用层可能还要继续发送剩余数据;等它也关闭发送方向时,再发自己的 FIN,最后由主动关闭方 ACK。因此逻辑上存在两个独立的半关闭过程:

A -> B: FIN
B -> A: ACK
B -> A: FIN
A -> B: ACK

但“四次挥手”描述的是四个逻辑报文动作,不保证线上一定看到四个独立 TCP Segment。如果 B 收到 FIN 后应用立即关闭,并且没有待发送数据,它对第一个 FIN 的 ACK 可以与自己的 FIN 合并在同一个 Segment 中,于是抓包可能表现为三次。关键条件是被动方何时进入关闭发送方向,而不是“开启延迟 ACK 就一定三次”。

其他情况也可能不同:双方同时主动关闭时会发生 Simultaneous Close;连接异常、进程强杀或设置特定 Socket 行为时可能通过 RST 中止,这不是正常的优雅关闭;中间设备和重传也会让抓包数量多于四个。

最后一个 ACK 之后,主动关闭方通常进入 TIME_WAIT,等待 $2\times\mathrm{MSL}$。原因是确保最后 ACK 丢失时还能重发,并让旧连接中的延迟报文在网络中消失,避免污染相同四元组的新连接。TIME_WAIT 不是“无用等待”,但在高并发短连接服务中需要通过连接复用、合理的端口与内核参数治理,而不是粗暴关闭协议保护。

FIN 也占用一个序列号,ACK 确认的是对方 FIN 的下一个序列号。收到 FIN 只关闭接收方向中的“对方继续发送”能力,本端仍可以发送数据,这正是 TCP Half-Close 的语义。

因此,TCP 关闭需要两个方向分别确认,但四个逻辑动作可以合并成更少的报文段;是否合并取决于应用关闭时机和协议栈行为。

20. 手撕:实现执行器,相同key串行、不同key并行

面试官: 实现一个执行器,相同key的任务串行执行,不同key的任务并行执行。

回答:

核心不变量有两个:同一个 Key 在任意时刻最多运行一个任务;不同 Key 的任务可以交给共享线程池并行运行。不能只用 ConcurrentHashMap<Key, Queue> 就认为线程安全,还要原子处理“入队、启动消费者、队列清空和删除 Key”之间的竞态,否则可能出现两个消费者同时运行,或新任务加入旧队列后无人继续消费。

下面的实现为每个 Key 建立一条 Lane。Lane 内使用短 synchronized 临界区保护队列和运行状态,实际任务在线程池中执行,不会持锁运行。Lane 关闭与 Map 删除之间通过 closed 和条件删除处理竞态。

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import java.util.concurrent.RejectedExecutionException;

public final class KeyedExecutor<K> {
    private final ConcurrentHashMap<K, Lane> lanes = new ConcurrentHashMap<>();
    private final Executor executor;

    public KeyedExecutor(Executor executor) {
        this.executor = Objects.requireNonNull(executor);
    }

    public CompletableFuture<Void> submit(K key, Runnable action) {
        Objects.requireNonNull(key);
        Objects.requireNonNull(action);

        Task task = new Task(action);
        for (;;) {
            Lane lane = lanes.computeIfAbsent(key, Lane::new);
            if (lane.enqueue(task)) {
                return task.result;
            }
            // 该 Lane 已完成关闭,帮助移除后重试到新 Lane。
            lanes.remove(key, lane);
        }
    }

    private final class Lane {
        private final K key;
        private final ArrayDeque<Task> queue = new ArrayDeque<>();
        private boolean running;
        private boolean closed;

        private Lane(K key) {
            this.key = key;
        }

        boolean enqueue(Task task) {
            boolean shouldSchedule = false;
            synchronized (this) {
                if (closed) {
                    return false;
                }
                queue.addLast(task);
                if (!running) {
                    running = true;
                    shouldSchedule = true;
                }
            }
            if (shouldSchedule) {
                scheduleNext();
            }
            return true;
        }

        private void scheduleNext() {
            try {
                executor.execute(this::runOne);
            } catch (RejectedExecutionException ex) {
                failRemaining(ex);
            }
        }

        private void runOne() {
            Task task;
            synchronized (this) {
                task = queue.pollFirst();
                if (task == null) {
                    running = false;
                    closed = true;
                }
            }

            if (task == null) {
                lanes.remove(key, this);
                return;
            }

            if (!task.result.isCancelled()) {
                try {
                    task.action.run();
                    task.result.complete(null);
                } catch (Throwable ex) {
                    task.result.completeExceptionally(ex);
                }
            }

            // 前一个任务完全结束后,才调度同 Key 的下一个任务。
            scheduleNext();
        }

        private void failRemaining(Throwable cause) {
            List<Task> pending;
            synchronized (this) {
                if (closed) {
                    return;
                }
                closed = true;
                running = false;
                pending = new ArrayList<>(queue);
                queue.clear();
            }
            lanes.remove(key, this);
            pending.forEach(task -> task.result.completeExceptionally(cause));
        }
    }

    private static final class Task {
        private final Runnable action;
        private final CompletableFuture<Void> result = new CompletableFuture<>();

        private Task(Runnable action) {
            this.action = action;
        }
    }
}

复杂度方面,提交和出队平均为 $O(1)$;空间复杂度为 $O(K+N)$,其中 $K$ 是活跃 Key 数,$N$ 是排队任务数。单个 Key 的吞吐受串行约束,不同 Key 的并行度受底层 Executor 限制。

生产实现还应补充:每个 Key 的队列上限和背压;任务 Deadline 与取消;Metric 和 Trace;线程池关闭后的拒绝策略;热点 Key 公平性;Key 长期不释放的泄漏检查;以及是否允许前一个任务失败后继续执行后续任务。若任务有外部副作用,还要使用幂等键。

原始常见写法中,先发现队列为空,再分别删除 processing 和 queues,可能与并发 submit() 交错,造成任务落入即将被删除的旧队列。这道题真正考查的不是会不会使用并发容器,而是能否证明“每个 Key 只有一个消费者”并正确处理队列从空到非空、再从非空到空的状态转换。

本篇目录
  1. 01
  2. 02
  3. 03
  4. 04
    **面试官:** Agent执行多步任务时,对话历史加工具返回结果很容易超限,你有什么处理方案?
  5. 05
  6. 06
    **面试官:** 一个Agent任务耗时很长,用户等不及了,怎样停止这个任务?这个机制你实际尝试过吗?
  7. 07
    **面试官:** 一个Agent任务耗时很长,用户等不及了,怎样停止这个任务?这个机制你实际尝试过吗?
  8. 08
  9. 09
  10. 10
  11. 11
    **面试官:** RAG为什么需要设计父子索引?检索小文本块、返回大父文档能解决什么问题?
  12. 12
  13. 13
  14. 14
    **面试官:** Agent调用工具后,工具返回结果的消息结构怎么设计?为什么需要单独的Tool Message?
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30

同作者面经

展开其余 6 篇收起其余 6 篇

相关公司