2026-10-23 AI Agent 面试训练
今日学习主题
把约束前移:在最便宜、最早的环节拦住错误。
今天 5 道题共用一条主线——同一个约束,放在越早的环节立住,代价越低;等到最后一层才拦,代价往往变成不可控的尾延迟、静默错误或级联故障。
- B01:能在编译期拒绝的值,不要等到运行时才发现——型变规则、泛型实现与静态类型的覆盖边界。
- B02:能用结构化参数表达的查询意图,不要交给模型自由改写——Query Rewrite 的作用位置与失效边界。
- S01:能在入口拦住的过载,不要让它排队到超时——GPU 利用率为什么不是容量信号。
- S02:能在应用侧定死的加锁顺序,不要交给死锁检测去收拾——死锁、锁等待与重试放大。
- L01:能在进入子树前剪掉的分支,不要等到叶子才回退——回溯搜索里的剪枝与去重。
题目速览
| 编号 | 题目 ID | 题目名称 | 题型 | 一级类型 | 难度 | 一句话考察点 |
|---|---|---|---|---|---|---|
| B01 | Q-20261023-B01 | 泛型容器为什么不变 | 非 Agent 基础 | 编程语言与运行时 | 基础题(中高) | 型变的安全方向由读写位置决定,静态类型只覆盖编译期与单进程 |
| B02 | Q-20261023-B02 | Query Rewrite 的收益与失效边界 | Agent 基础 | RAG 与搜索 | 基础题(中高) | 改写只作用于查询侧,标识符类查询禁止自由改写 |
| S01 | Q-20261023-S01 | GPU 利用率 70%,P95 却涨了 8 倍 | Agent 场景 | LLM 应用工程 / AI Agent 核心 | 场景题(高) | 排队不可见、准入缺失,利用率不是容量信号 |
| S02 | Q-20261023-S02 | 偶发死锁,加了自动重试反而更糟 | 非 Agent 场景 | 数据库与存储 / 分布式系统 | 场景题(高) | 锁序是应用侧契约,重试只用于兜底且必须带抖动 |
| L01 | Q-20261023-L01 | 39. 组合总和 | LeetCode | 回溯 | medium | 可重复选择的组合搜索,排序 + 起点 + 剪枝 |
Q-20261023-B01 · 非 Agent 基础定义题
- 题目 ID:Q-20261023-B01
- 一级类型:编程语言与运行时
- 二级主题:类型系统、接口与泛型
- 难度:基础题(中高)
- 考察点:
- 协变、逆变、不变的形式定义与安全方向判定(读位置协变、写位置逆变)。
- 可变容器必须不变的原因,以及用通配符/受限签名修复的正确做法。
- 泛型的三种实现路径(擦除、单态化、具化)如何决定运行时能否拿到实参类型。
- 静态类型的作用域边界:编译期 + 单进程,跨进程数据必须在入口补运行时校验。
- 用类型编码单位与身份(newtype / branded type)把语义错配提前到编译错误。
题目
一个函数的签名是 process(items: List[Animal]),而 Dog 是 Animal 的子类。把 List[Dog] 当实参传进去,大多数静态语言会直接拒绝。请回答:
- 这一限制的形式名称是什么?它的安全规则如何表述?
- 如果语言的泛型是擦除实现(如 Java)还是单态化/类型见证实现(如 Go、Kotlin 的 reified),运行时能否拿到
T的真实类型?这对反序列化意味着什么? - 静态类型系统覆盖的边界到底在哪里——特别是在跨进程、跨语言、反序列化之后?
先思考
- 先判断这个容器的类型参数出现在读(输出)位置还是写(输入)位置,或者两者皆有。
- 再反证一次:如果
List[Dog]真能赋给List[Animal],调用方能不能往里面塞一只Cat。 - 然后区分”编译期已擦除”与”运行时仍可取”这两类泛型实现对反序列化 API 的影响。
- 最后把边界定清楚:类型的作用域是单个进程的编译期,还是跨进程的数据契约。
参考答案
结论:这是**不变(invariance)**约束——泛型类型参数同时出现在读和写两个位置时,容器不能随元素类型协变;同时必须意识到”编译期通过”只覆盖单进程内的静态检查,跨进程交付的数据仍要在边界处补一次运行时校验。
定义:设存在子类型关系 Dog <: Animal,对类型构造子 C<T>:
- 协变(covariance):
A <: B则C[A] <: C[B] - 逆变(contravariance):
A <: B则C[B] <: C[A] - 不变(invariance):
C[A]与C[B]之间没有子类型关系
安全规则按位置判定:只出现在返回/输出位置的类型参数可以安全协变;只出现在参数/写入位置的类型参数可以安全逆变;同时出现在两类位置则必须不变。可变容器既有 get(): T 又有 add(T),所以必须不变。
适用边界:型变是编译期的静态规则,作用域是类型检查器能看见的那一段代码。它不保证运行时数据合法,对类型注解(动态语言)、反射构造的对象、跨进程字节流完全不可见;它也不是安全边界——断言、unsafe、any 都可以绕开。
简洁示例:
List<Dog> dogs = new ArrayList<>();List<Animal> animals = dogs; // 编译错误:要求 List<Animal>,提供 List<Dog>// 若上面成立,下面这行就把 Cat 塞进了原本只装 Dog 的列表:// animals.add(new Cat());Dog d = dogs.get(0); // 运行时 ClassCastException正确修复是改签名而不是强转,Java 用通配符配 PECS(Producer extends, Consumer super):
void readOnly(List<? extends Animal> src) { Animal a = src.get(0); /* src.add 不可用 */ }void writeOnly(List<? super Dog> dst) { dst.add(new Dog()); /* get 只得到 Object */ }Go 1.18 走的是另一条路:切片不支持型变,[]Dog 与 []Animal 没有任何赋值关系;并且类型约束必须是接口类型,约束定义的是一个”类型集合”,泛型函数体内允许的操作必须是该集合中所有类型都允许的操作——所以只有约束为 constraints.Ordered 时才能写 <。
相近概念区别:
- 继承的子类型 vs 泛型的型变:前者是类型之间的关系(Dog 是 Animal),后者是”类型构造子是否保持这种关系”(
List[Dog]是不是List[Animal]),不可类推。 - 泛型 vs
any/ 空接口 / 反射:泛型保留静态可检查性与特化后的性能;any与反射把类型信息推到运行时,编译器不再拦错。 - 静态类型 vs 运行时 schema 校验:前者管”本进程内、编译期”,后者管”跨进程、反序列化后”,二者互补,不可互换。
典型应用(通用工程):
- API 边界:DTO 与领域模型用不同类型表达,避免内部类型被直接序列化到对外契约上。
- 单位与身份:
Milliseconds、Money、AccountId用 newtype / branded type 表示,禁止裸int64/float,把”秒传成毫秒""元传成分”从评审问题变成编译错误。 - 反序列化:CSV/JSON 进入系统时统一过 schema 与判别字段,不要靠
Map<String, Object>加一堆类型兼容逻辑兜底。
面试现场回答(1 至 2 分钟):
这是不变性约束。
Dog是Animal的子类型,不代表List[Dog]是List[Animal]的子类型,因为容器既能读又能写:如果允许赋值,调用方就能往原本装 Dog 的列表里加一只 Cat,读取时才崩,错误被推迟且位置不明。规则看位置——返回位置可以协变,参数位置可以逆变,两者都有就必须不变。所以正确做法不是强转,而是改签名:只读用? extends Animal,只写用? super Dog,这就是 PECS。第二层是泛型实现:擦除型泛型运行时拿不到T,反序列化必须额外传 Class 或 TypeToken;Go 则是类型集合约束且不支持容器型变。最后边界要讲清楚,静态类型只在编译期、单进程内有效,跨进程进来的数据必须用运行时 schema 再校验一道,类型注解管不了边界外的数据,而通常最严重的错误恰好来自边界之外。
关键点
- 型变的安全方向由位置决定:输出位置协变安全,输入位置逆变安全,读写双通道必须不变。
- 容器不变是”是否允许写入”的取舍结果,不是语言设计的疏漏。
- 泛型实现分三类:擦除(运行时不可得)、单态化(编译期展开,性能最好但代码膨胀)、具化(保留运行时类型),反序列化能力直接取决于这一层。
- 静态类型的覆盖边界是编译期 + 单进程;跨进程、跨语言、反射构造的数据不在其范围内。
- 用类型编码单位与身份是最廉价的防御:把语义错配从运行时事故提前到编译错误。
常见误区
- 认为运行时一定能拿到
T:Java 擦除之后List<String>与List<Integer>运行时是同一个类,JSON 反序列化必须显式传入类型令牌或判别字段。这不是库设计偷懒,是擦除语义的直接后果。 - 认为编译通过等于数据合法:Python 的 typing 注解默认只被静态检查器读取,运行期不做校验;动态语言尤其需要入口处的显式 schema 校验。
- 用
any或反射”省事”替代泛型:同时丢掉静态可检查性与 IDE 推导,把错误从编译期推到运行时;应当用带约束的泛型表达”这一类类型都能接受”。
延伸学习
- Go 官方博客《An Introduction To Generics》(Robert Griesemer 与 Ian Lance Taylor,2022-03-22,go.dev/blog/intro-generics):官方明确”类型约束必须是接口类型”,且”在泛型函数体内,只有当操作被约束的类型集合中所有类型都允许时,该操作才被允许”,因此
constraints.Ordered才是使用<的前提。 - Oracle Java 教程《Type Erasure》(docs.oracle.com/javase/tutorial/java/generics/erasure.html,本轮未核验):进一步说明擦除规则与桥方法。
Q-20261023-B02 · AI Agent / LLM 基础定义题
- 题目 ID:Q-20261023-B02
- 一级类型:RAG 与搜索
- 二级主题:Query Rewrite、召回入口的职责分工与可归因性
- 难度:基础题(中高)
- 考察点:
- Query Rewrite 只改变检索输入,不改变语料与索引质量,因此主要影响召回而非精度。
- 适用与不适用场景:词面缺失型(代词、省略、口语)该改写,标识符型(订单号、金额、条款号)禁止自由改写。
- 多轮 Agent 中的职责分层:实体绑定由编排层确定性完成,意图补全才交给模型。
- 与 Query Expansion、重排、HyDE、多路融合的区别与成本。
- 改写模块本身的可观测性与降级路径。
题目
一个多轮问答 Agent 的检索链路是:用户输入 → 检索工具 → 向量库 + BM25 → 重排 → 生成答案。团队计划在检索前增加一步”查询改写”,由一个 LLM 把用户的自然语言 query 改写成更适合检索的版本,或者展开成多条查询。
请回答:
- 查询改写究竟改变了检索链路中的什么,它改变不了什么?
- 哪些查询应该改写,哪些查询改写之后反而有害?请给出判据而不是感觉。
- 如果把一条查询 fan-out 成多条,收益和代价分别怎么度量?当这个改写模块本身失败时,应该如何归因?
先思考
- 先区分”查询的词面”与”用户的意图”——检索器只能匹配前者。
- 再想哪些查询存在”意图清楚但词面缺失”:代词、省略、口语化表达。
- 然后反过来想:哪些查询的词面本身就是契约的一部分,改一个字符就召回错。
- 最后问一句:如果线上指标变差,你靠什么判断是改写错了、召回错了,还是生成错了。
参考答案
结论:查询改写只改变提交给检索器的查询表达,让它更接近被检索文档的表达空间;它既不改变语料,也不改善切片与索引质量。因此它主要改善召回,对精确率可能是负收益——凡是”词面即契约”的查询都不应交由一个语言模型自由改写。
定义:查询改写(Query Rewrite / Query Transformation)是在检索执行之前,把用户原始输入变换为一个或多个检索用查询的过程。常见形态有四类:指代消解与省略补全(依赖多轮上下文)、拼写纠正与术语规范化、分解型多查询(把复合问题拆成若干子查询)、生成式改写(先生成一段”假设文档”,再用它的嵌入去检索,即 HyDE)。
适用边界:
- 有效的场景:用户表达与文档表达之间存在词汇鸿沟;查询过短、含代词、依赖会话上下文;领域术语与口语混用。这些恰好是多轮 Agent 的高频形态。
- 无效或有害的场景:查询中包含必须精确匹配的标识符,例如订单号、SKU、金额、日期区间、法律条款编号、代码符号;已存在高精度的结构化检索通道;以及改写结果没有任何可执行的校验手段。
- 能力边界:改写作用于查询这一侧,它补不上”语料里根本没有”的知识,也不能弥补切片策略或索引构建方式的缺陷。把”召回不到”归因于改写,常常是在掩盖真正的问题。
简洁示例:
第 1 轮:用户 —— "订单 A20231 怎么退款?"第 2 轮:用户 —— "那它的保修期呢?"把第 2 轮的原句直接送去检索,向量近邻大概率是”保修政策”与”退款政策”混杂。正确的改造应拆成两件事承担:
- 确定性可推导的部分:绑定实体。“它”指订单 A20231 对应的商品 SKU,这是编排层拿着会话状态就能算出来的,不需要问模型,错了是系统缺陷。
- 需要语义补全的部分:把”保修期呢”补成”质保期限 政策 时长”这样的检索词面。
最终送给检索工具的应当是结构化参数加自然语言查询:{"sku": "SKU-8891", "query": "质保 期限 政策"}。这两部分的归属就是本题的核心分工。
相近概念区别:
- Query Rewrite vs Query Expansion(含伪相关反馈):改写是替换或重写查询表达;扩展是保留原查询并追加候选词或多条查询,用于人为扩大召回面。前者改变输入,后者增加输入的并集。
- 改写 vs 重排(Rerank):改写发生在召回之前,改变检索输入;重排发生在召回之后,只改变候选顺序。所以”改写提升召回、重排提升精度”只是经验说法,改写若引入错误词面同样会伤害精度。
- HyDE 与普通改写:HyDE 的做法是让指令跟随模型先生成一个”假设文档”,再用无监督对比学习编码器把它压成向量,在语料嵌入空间中取近邻;原文明确指出该文档”捕获相关性模式但并不真实,可能包含错误细节”,要靠编码器的稠密瓶颈把错误细节过滤掉(Gao 等,arXiv:2212.10496)。差别在于:普通改写产出的是查询侧词面,HyDE 产出的是文档侧的伪证据,因而对编码器的质量依赖更强。
- 多路召回融合:一旦 fan-out 出多条查询,候选集合的合并策略(去重、配额、分数融合)会引入新的变量。这部分收益必须单独评测,否则会把”改写有效”和”融合有效”混为一谈。
在 Agent 系统中的应用:
- 职责分层:多轮上下文的实体绑定是确定性可推导的,应由编排层用结构化会话状态完成;只有意图补全与词面规范化才交给模型。把实体解析也丢给模型,等于是用概率组件承担可以确定完成的工作,而且出错后无法定位。
- 工具参数即契约:检索工具的入参应该包含结构化字段(租户、时间范围、实体 ID、过滤条件),而不是把一切拼进一句自然语言。这样既让过滤与检索各自可测,也让”scheme 层的约束”真正发生作用。
- 可观测与归因:trace 中必须同时记录原始 query、改写后 query、改写模型与版本、耗时以及是否走了降级路径。否则出现”检索没召回”时,无法判定是检索器的问题还是改写引入了错误意图。
- 失败路径:改写模型超时或被限流时,应当降级为不改写(保留原 query)而不是让整个检索失败;改写结果要做结构化校验:不得改动标识符白名单内的片段、长度上限、不得引入原文没有出现过的数字。
面试现场回答(1 至 2 分钟):
查询改写改的是”送给检索器的查询表达”,让它更接近文档侧的表达空间;它既不改变语料,也不改善索引和切片质量,所以主要作用于召回而不是精度,这点必须先讲清楚。判断该不该改写,看查询里是不是存在”意图清楚但词面缺失”的问题——多轮里的代词、省略、口语都适合改;反过来,只要查询里出现必须精确匹配的标识符,比如订单号、金额、条款编号,就不能让模型自由改写,应该走结构化工具参数。在多轮 Agent 里我会做职责分层:实体绑定由编排层拿会话状态确定性地算出来,“它”指哪个订单不需要问模型;只有意图补全和词面规范化交给模型。工程上要把原始 query、改写后 query、改写模型和版本全写进 trace,并且要有”改写失败就降级为不改写”的路径加上标识符白名单校验,否则线上指标一变差,你根本无法归因是检索错了还是改写错了。最后 fan-out 成多条查询,收益用召回率单独测,代价是延迟、成本和后续的融合复杂度,而且它经常带来”召回上升、精度下降”的结果。
关键点
- 改写只改变检索输入,不改语料与索引质量;主要影响召回而非精度。
- 标识符类查询禁止自由改写,应走结构化工具参数加白名单校验,并保留降级为不改写的路径。
- 多轮场景中实体绑定是确定性工作,意图补全是概率性工作,二者必须分开实现与归因。
- HyDE 产出的是文档侧伪证据,依赖编码器瓶颈过滤错误细节,对编码器质量更敏感。
- 观测必须双记录原始 query 与改写后 query,并把改写模型版本纳入,否则不可归因。
常见误区
- 对所有 query 无条件改写:货币单位、年份、型号连字符这类细节被模型改掉之后,召回会直接塌掉,而且是静默失败,没有任何异常堆栈。
- 把改写当作提升精度的手段:精度更多取决于召回质量、重排与切片策略;只用”答案正确率”这一个指标衡量改写效果,会把多个变量混在一起,得出错误结论。
- 改写不进 trace、不设降级路径:改写调用本身会超时、被限流、返回不可解析的结构;一旦没有降级路径,一个增强模块就会绑架整个检索的可用性。
延伸学习
- arXiv:2212.10496《Precise Zero-Shot Dense Retrieval without Relevance Labels》(Luyu Gao、Xueguang Ma、Jimmy Lin、Jamie Callan,2022-12-20 提交,cs.IR):HyDE 原始论文。原文明确生成文档”捕获相关性模式但并不真实,可能包含错误细节”,第二步靠编码器”[把生成的文档] 落回真实语料”。
- 动手方向(无需外部依赖):取 30 条真实业务 query,人工分成”含标识符”与”纯口语/含指代”两组,分别做改写开与关的 recall@10 对照,量化两类查询上的收益差——这一步能直接纠正团队对改写效果的直觉。
Q-20261023-S01 · AI Agent 真实研发场景题
- 题目 ID:Q-20261023-S01
- 一级类型:LLM 应用工程(兼属 AI Agent 核心)
- 二级主题:部署、扩缩容与容量规划:推理排队、动态批处理与准入控制
- 场景类型:部署、扩缩容和容量规划(配置 v6 场景题类型,此前未出题,本次首次使用)
- 难度:场景题(高)
- 考察点:
- GPU 利用率为什么不是容量信号:它是时间占比,不含排队信息,且会被批处理策略掩盖。
- Agent 会话对推理请求的放大倍率及其重尾分布:平均步数会掩盖 P95 的真实负载。
- 并发上界由 KV Cache 显存决定,而不是由”想要多大的 batch”决定。
- 准入控制、关键性分级与每租户配额如何替代”事后扩容”。
- 三层能力归属:模型的概率性、编排层的确定性预算、传统软件的保护机制。
题目
你们运营一个企业内部的 Agent 平台,支撑客服与运维两类会话助手。用户的一次提问会被编排器展开成多步执行:规划 → 检索或工具调用 → 校验 → 回答。
已知条件:
- 业务规模:白天峰值 800 会话 QPS;平均每会话 6 次模型调用、2.4 次工具调用;少数复杂会话超过 20 步。
- 当前架构:网关 → 编排服务(有事件历史的状态机)→ 推理服务(动态批处理,自部署)→ 工具网关 → PostgreSQL 与向量库。
- 容量策略:由 GPU 利用率驱动扩缩容,低于 55% 缩容,高于 70% 扩容。
- 目标:会话完成时间 P95 ≤ 30 秒;单步首 token 延迟 TTFT P95 ≤ 2 秒;单位成本不超过预算。
- 限制:GPU 采购周期 8 周,短期内只能做软件侧改造;6 个租户共享同一推理池,其中 3 个大客户的 SLO 已写进合同;不允许为了保住尾延迟而把所有请求都排到小批量执行,吞吐与成本有底线。
故障现象:业务高峰期 P50 从 4 秒涨到 6 秒,但P95 从 22 秒涨到 180 秒,触发上游超时与用户重发。监控面板显示 GPU 利用率稳定在 65% 到 70%,值班据此判断”集群还有余量”,扩容动作迟迟没有发生,故障窗口持续 40 分钟。
请给出诊断结论、分层处置方案、取舍依据、失败路径与上线验证方式。
先思考
- 先问一句:GPU 利用率 65% 是否意味着排队为零?注意它是”过去一段时间里 GPU 忙的比例”,而排队发生在它被占用之前。
- 再问:用户的一次会话,在模型侧被放大成了多少次请求?这个放大倍率本身是不是稳定分布的?
- 然后想:如果系统既没有准入控制、队列又无界,多余的负载会体现在哪个指标上?(提示:不会体现在利用率上。)
- 接着想:KV Cache 的显存占用如何决定”能同时跑多少条请求”,它与批处理大小是什么关系。
- 最后归位:哪些是不可预测的模型行为,哪些是可以被编排层定死的预算,哪些是应当由传统软件承担的保护。
参考答案
结论先行(30 秒版):这不是”资源不够”,而是排队没有出口。GPU 利用率是”过去一段时间里 GPU 忙的时间占比”,它既不包含排队等待,也不反映到达突发;当请求的服务时间呈重尾分布(不同会话的 token 长度与步数相差一个数量级),而队列无界、没有准入时,多余负载会先转成队列深度,再转成尾延迟,最后才表现为利用率饱和——等到利用率报警,已经晚了 40 分钟。处置要分三层:编排层给会话定预算、推理服务做准入与优先级、可观测层把扩缩容信号换成排队等待时间与 KV Cache 水位。
一、问题拆解(四条互相独立的主线,每条都要有判据)
- 放大倍率:用户侧 800 会话 QPS 不等于模型侧 QPS。模型调用 QPS ≈ 会话 QPS × 平均步数 ≈ 4800;而”平均 6 步”掩盖了分布形态,复杂会话 20 步以上意味着最重的那 5% 会话占用了远超其份额的资源。判据:按会话统计步数分布与总 token 分布,看 P95 与 P50 的比值。
- 服务时间重尾:单次调用成本 ≈ 输入 token 量(决定 prefill 计算量)+ 输出 token 量(决定 decode 迭代次数),两端都能在 10 倍区间波动。排队系统里方差和均值同样重要:单服务台的平均排队长度大致按 ρ/(1−ρ) 增长,利用率越接近 1,延迟上升越是超线性;而队头阻塞会让短请求被长请求挡住。判据:单请求服务时间的 P99 与 P50 之比。题目里”P50 只涨 2 秒、P95 涨 8 倍”就是重尾加排队的典型形态。
- 队列不可观测:故障描述里没有任何队列等待指标,这是核心缺陷之一。没有排队延迟的分位数,就只能看到 GPU 利用率这个”事后指标”,也就不可能在故障的第一分钟做出判断。判据:能否把 queue time 与 service time 分别打到监控上。
- 无准入、无优先级:负载超限时系统只能被动排队,超时后上游重发,负载进一步上涨,形成自激的正反馈。判据:超时率与到达率是否同步上升。
二、核心方案
(1)编排层:给会话定预算,把开放式行为变成有限状态机
为每类任务设定步数预算、token 预算、工具调用预算与墙钟 deadline;deadline 必须沿调用链传导到推理服务与工具网关——到达 deadline 的请求应当主动放弃排队,而不是排队到那一刻才发现自己已经超时。预算耗尽时按预设路径降级:减少重规划次数、切换到小模型、裁剪检索深度,最后一步才是”转人工或返回部分结果”。这一步的价值在于:步数是编排层可以用确定性方式约束的量,不应该指望模型自己收敛。同时,检索的实体绑定、参数校验、引用核对、安全策略判定这些确定性工作,不该占用昂贵的推理槽位。
(2)推理服务层:连续批处理加以显存为界的准入
采用动态或连续批处理,让请求按迭代粒度进出批次,而不是整批等待。并发上界不由”想开多大的 batch”决定,而由 KV Cache 显存决定:单条请求的 KV Cache 占用大致为 层数 × KV 头数 × head_dim × 2 × 序列长度 × 每元素字节数,显存耗尽就无法再接纳新请求。PagedAttention 的思路正是把这段显存按页管理,消除碎片与冗余复制,从而在同等延迟水平下把吞吐提高 2 到 4 倍(Kwon 等,arXiv:2309.06180,SOSP 2023)。换句话说,先看清”并发上界是被显存钉死的”,才能理解为什么准入控制不是可选项。 此外,一次长 prefill 的插入会拖慢同批的所有 decode,需要用分块 prefill 或对超长输入限流来压住这种批内抖动。
过载时必须做工程上的取舍,而不是无限排队:按请求的关键性分级,利用率逼近阈值时从低关键性开始拒绝,高关键性拥有更高的拒绝阈值;同时对不同客户设全局配额,让超额客户单独被拒而不牵连其他租户;客户端侧的自适应限流则让多余流量在调用方本地快速失败,不进入网络。Google SRE 的表述很直接——“配置成服务某一流量速率的后端,应当继续以该速率服务而不显著影响延迟,无论有多少超额流量被扔过来”。
(3)可观测层:换信号、加出口
扩缩容的主信号改为排队等待时间 P95(或队列深度)与 KV Cache 水位,GPU 利用率降为辅证。SRE 里那句教训可以直接引用:把容量建模成 QPS,或者用”我们认为能代表资源消耗的静态特征”做代理,往往是个很糟的指标。所有长队列都必须有出口:队列有界,并且队头的请求要检查”已等待时间是否超过 deadline”,超时即丢弃。
三、关键执行流程
- 网关按租户做配额与关键性判定,超额请求直接快速拒绝——拒绝一个请求消耗的资源远少于把它真正执行完。
- 编排服务为会话建立预算与 deadline,写入追踪上下文并随调用链传播。
- 每次模型调用携带 deadline、关键性、租户标签提交给推理服务。
- 推理服务做准入判定:KV Cache 是否有空间、队列是否超限、该关键性的阈值是否允许接纳。
- 进入连续批处理执行,prefill 与 decode 分别计时。
- 任一层触发超时或降级,都回写追踪记录并扣减会话预算;预算耗尽进入降级路径。
- 会话结束时做成本归因:按租户、任务类型、模型与步数统计 token 与花费。
四、技术选型、依据与备选
| 决策点 | 选择 | 依据 | 备选及不选的理由 |
|---|---|---|---|
| 扩缩容信号 | 排队等待 P95 + KV Cache 水位 | 二者都是”即将或正在排队”的前置信号 | GPU 利用率是时间占比,滞后且被批处理策略掩盖;QPS 忽略了不同请求成本相差一个量级 |
| 过载保护位置 | 客户端 + 服务端双层,按关键性与租户配额 | 让超额流量在最早、最便宜的位置失败 | 只在网关限流无法保护内网调用与后台批处理任务 |
| 批处理策略 | 连续批处理 + KV Cache 分页管理 | 提高并发上界而不显著抬高尾延迟 | 静态大批能提吞吐,但尾延迟不可控 |
| 会话成本控制 | 编排层的步数与 token 预算 | 步数是确定性可约束量 | 指望模型自行收敛无法给出 P95 承诺 |
五、方案取舍(必须点明代价)
- 批处理吞吐 vs 单请求延迟:批越大,吞吐越高、单卡成本越低,但尾延迟越高。正确做法是按池分层——交互请求走小批低延迟池,可延迟任务走大批后台池,而不是全集群套一个 batch size。
- 利用率 vs 尾延迟:追求高利用率必然接受长排队。C 端合同租户一侧要留出显著降低的目标利用率,低优先任务一侧才允许推高。
- 公平 vs 优先级:纯 FIFO 公平,但长任务会拖累短任务;按关键性抢占又牺牲批处理租户的公平性,需要用配额把这种不公平收敛到可声明的范围内。
- 通用工作流 vs 特定场景优化:给 3 个大客户做专用显存配额与更早的准入拒绝确实更有效,代价是运维复杂度上升,且每多一条隔离边界就要独立验证一次租户泄漏通路。
六、失败路径、边界条件与恢复
- 推理服务显存溢出:KV Cache 超订导致分配失败时,必须按优先级从低到高回收在途请求,而不是让整个节点崩掉;回收动作要有确定顺序并写进追踪记录。
- 上游已放弃但请求仍在排队或执行:deadline 必须传导到服务端,服务端发现已超时即从队列移除并标记为取消。对长耗时工具调用同样适用——持久句柄需要具备协作式取消语义,取消请求本身也要求幂等。
- 节点被驱逐或模型冷启动:要有优雅退出与排空机制;冷启动期间不接入高关键性流量,先用低优先流量预热,避免把冷启动成本转嫁给合同租户。
- 重试放大:重试只能在紧邻的上层发生一次,多层同时重试会造成组合爆炸;按 SRE 的经验做法,给每个请求设重试预算(最多若干次尝试),并且后端要能把”过载,请勿重试”与”过载,可以重试”区别返回。所有重试都必须带抖动,且只针对可重试错误。
- 幂等:工具副作用由编排层的写闸门与幂等键保护,重试不得绕过写闸门;共享产物按键去重,避免”重试一次就重复退款一次”。
七、安全、成本、延迟与可靠性
- 安全与多租户:KV Cache 与提示缓存的前缀不得跨租户复用;网关侧先判定租户归属再决定是否允许复用共享前缀,一旦发生跨租户命中,属于安全事件而不是性能问题。
- 成本:输入与输出 token 的计费不对称,长输入会话应绑定缓存复用并按租户归因;成本口径写成”每成功会话花费”而不是”每千 token 花费”,否则步数膨胀导致的成本失控不会暴露在账单上。
- 延迟:必须区分 TTFT、token 间延迟与端到端会话完成时间——排不上队影响 TTFT,批内争抢影响 token 间延迟,步数膨胀影响端到端时间,三者成因不同,治理手段也不同。
- 可靠性:单节点故障时应能把会话重放到另一节点重放依赖编排层的事件历史;重放的前提是所有外部副作用具备幂等键,否则”从断点继续”会变成”重复执行”。
八、评测指标、测试方法与上线观测
- 指标:按关键性分桶的 TTFT P50/P95/P99;排队等待时间分位数;KV Cache 水位与抢占次数;prefill 与 decode 的时间占比;每租户配额使用率与拒绝率;步数分布的 P50/P95;token 与花费的每会话归因;重试率。
- 测试:影子流量回放加合成负载注入,重点注入”步数处于 P99”的重尾会话,验证满载时队列是否被限住、拒绝是否按关键性正确分层、被拒请求是否真正快速失败而不是慢慢超时。
- 上线:先在单一租户、单一任务类型灰度;守护窗口内监测 SLO 消耗速率,超阈值只回滚批处理与准入参数,不回滚整个版本——这些参数应当是可以在线调回旧值的配置,而不是硬编码常量。
九、面试官追问(逐渐深入)
- 追问:GPU 利用率 70% 但 P95 抖到 180 秒,一线该加卡还是先看指标?
回答要点:先看排队等待 P95 与 KV Cache 水位。若等待时间占比高,加卡只能线性抬升能力,解决不了重尾分布与缺少准入这两个根因;把队列改造成有界且可见之后,才能判断”这是容量不足还是排队管理缺失”,二者对应的投入完全不同。 - 追问:为了保吞吐把 batch 开到最大,结果 P95 反而恶化,怎么取舍?
回答要点:批处理提升的是吞吐,代价是批内阻塞与更长的最短等待。做法是拆池加按关键性排队,而不是全局套一个 batch size;评价时用排队时间而不是执行时间,否则会把”执行很快”误判为”体验很好”。 - 追问:一个会话在第 15 步失败了,重跑的成本是全额重来吗?
回答要点:不该是。持久化执行的事件历史让重放可以从失败的那一步继续,前提是外部副作用都带幂等键、编排状态可重建。把”重来一次”降级为”从断点继续”是编排层的能力,而不是模型的能力——这一点是区分 Agent 工程与传统无状态微服务的关键。
十、30 至 60 分钟小型实践
写一个 100 行以内的排队模拟器:到达服从泊松过程,服务时间用双峰分布(短请求 0.2 秒占 90%,长请求 3 秒占 10%),对比”无界队列”与”有界队列加达到时的快速丢弃”两种策略在利用率 0.6、0.8、0.9 时的 P95 总延迟;再用 层数 × KV 头数 × head_dim × 2 × 每元素字节数 算出单请求每 token 的显存占用,代入最大序列长度,估算一张 80GB 的卡能同时容纳多少条请求。
预期结论:利用率 0.8 时,P95 已经比 P50 高出一个数量级,且差别几乎全部来自排队;有界队列能把尾延迟钉住,代价是少量请求快速失败——这正是”在开始排队之前就拒绝”的价值。
关键点
- GPU 利用率是时间占比,不含排队信息;正确的容量信号是排队等待 P95 与 KV Cache 水位。
- 并发上界由 KV Cache 显存决定,因此准入控制不是可选项而是必然结果。
- 会话到模型调用的放大倍率是重尾的,平均步数会掩盖真实的 P95 负载;控制手段是编排层的步数与 token 预算。
- 过载必须在入口拦住并快速失败:按关键性分级拒绝、按租户设配额、客户端自适应限流,三者互补。
- 队列必须有出口:有界队列加 deadline 检查,避免”已经被上游放弃的请求还在排队”。
常见误区
- 把 GPU 利用率当成容量余量:批处理越激进它越高,反而可能伴随更长的排队;真正的排队信号是等待时间与队列深度。
- 把延迟治理等同于模型调优:本案的 P95 恶化几乎全部来自排队与步数膨胀,与模型质量无关;不测量就动手,会用换模型的方式去解决排队问题。
- 认为自动扩缩容能兜住突发:GPU 有 8 周采购周期与分钟级调度延迟,突发场景必须先有入口准入与编排层降级,扩容只是长期手段。
延伸学习
- Google SRE Book 第 21 章《Handling Overload》(sre.google/sre-book/handling-overload/):官方给出的按关键性拒绝、每客户配额、客户端自适应限流与重试预算的完整做法。已核验。
- arXiv:2309.06180《Efficient Memory Management for Large Language Model Serving with PagedAttention》(Kwon 等,SOSP 2023):理解显存管理与吞吐上限的关系。已核验。
Q-20261023-S02 · 非 AI Agent 真实研发场景题
- 题目 ID:Q-20261023-S02
- 一级类型:数据库与存储(兼属分布式系统)
- 二级主题:死锁、锁等待、加锁顺序与重试放大
- 场景类型:通用存储层的并发写冲突与重试治理(此前非 Agent 场景未覆盖该二级主题)
- 难度:场景题(高)
- 考察点:
- 死锁是应用侧加锁顺序的产物,数据库侧的死锁检测只是兜底而不是治理机制。
- 行级锁足以造成死锁,不需要任何显式加锁语句。
- 事务时长与影响行数决定冲突窗口;未被判为死锁时,等待是无界的。
- 重试的可重试性判定、重试预算与抖动;盲目即时重试会把局部冲突放大为连接池排队。
- 幂等键、流水式记账与热点账户去行锁化的备选设计。
题目
你们维护一个账务服务,支撑单笔转账与商户批量结算两类写入。
已知条件:
- 数据规模:
accounts表 2000 万行;单笔转账峰值约 3000 TPS;批量结算任务每天数百批,每批处理数百到数千个账户。 - 当前架构:60 个应用实例,每实例连接池 20 个连接,全部指向同一套 PostgreSQL 主库。单笔转账在一个事务里执行:
UPDATE accounts(付款方)→UPDATE accounts(收款方)→INSERT ledger两条流水。批量结算任务按不同的遍历顺序处理账户列表(有的按商户给出的名单顺序,有的并行多线程乱序提交)。 - 目标:消除偶发死锁;转账 P99 延迟保持稳定;不得出现重复入账。
- 限制:不允许停机;不允许把整个写路径串行化到单个队列(吞吐要求不满足);必须保证幂等。
故障现象:生产每天出现数次 deadlock detected(SQLSTATE 40P01)。团队在应用层加了”立即自动重试,最多 3 次”。上线之后单次故障窗口从几秒变成数分钟,转账 P99 从 30 毫秒涨到 2 秒以上,连接池被打满,最终出现大面积超时。请说明根因、治理方案与验证方式。
先思考
- 先问:数据库检测到死锁并在做什么?它是在”预防”还是在”事后拆锁定环”?
- 再想:两个事务没写任何
LOCK TABLE,为什么会死锁?锁是从哪一步取得的。 - 然后推演:即时重试会把同样的两条 SQL 以同样的顺序再撞一次,第二次的结果是什么。
- 接着看连接:重试占用的是连接池里的连接,连接池打满之后,谁会先被拖死。
- 最后想:哪些错误是可重试的(死锁、序列化失败),哪些绝对不可重试(唯一约束冲突、业务校验失败)。
参考答案
结论先行(30 秒版):根因是加锁顺序不一致,而不是数据库没把死锁处理好。PostgreSQL 官方文档给出的首要建议很明确:“防范死锁的最佳方式通常是避免它们——确保所有使用数据库的应用都以一致的顺序获取多个对象上的锁”,并且”事务在对象上获取的第一个锁,应当是该对象在该事务所需要的最严苛模式”。线上加重试之所以更糟,是因为即时重试把一次偶发的环形等待,变成了对同一批热点行的重复冲击,同时长时间占住连接池——无抖动、无预算的重试本身就是故障放大器。
一、问题拆解
- 死锁发生在哪:以两个事务为例,事务 1 先更新账户 A 再更新账户 B,事务 2 先更新 B 再更新 A,两者各自持有一个行锁再申请对方的锁,形成环。注意官方明确:即使完全不使用显式加锁,行级锁也会造成死锁。也就是说这是
UPDATE语句天然带来的,不是误用LOCK TABLE的后果。 - 为什么过去没那么严重:单笔转账的常态是随机账户对,撞环概率低;批量结算按名单顺序或多线程乱序更新,就把”AB / BA”的组合概率推高了几个数量级。
- 为什么加重试更糟:三条原因叠加。其一,即时重试会在同一瞬间把同样的两条语句以同样顺序再送一遍,等于把环形等待的概率再抽一次;其二,重试期间连接不会释放,连接池里的 1200 个连接被等待事务占满,健康请求排不进来,本地小故障扩散成全站超时;其三,故障窗口从”一次事务失败”变成”三次尝试 + 每次等待到检测出死锁”,单次请求耗时成倍放大。
- 为什么”没死锁的事务也很危险”:官方写明,只要没被判定为死锁,申请表级或行级锁的事务会无限期等待冲突锁释放,“因此让事务长时间保持打开(例如在等待用户输入期间)是个坏主意”。这意味着冲突不一定表现为死锁错误,也可能表现为长时间挂住的连接。
二、核心方案(按优先级)
- 统一加锁顺序(治本,必须先做):任何需要更新多行/多账户的场景,一律把目标标识排序后按单调顺序更新。批量任务在提交前对账户 ID 列表排序,并且在多线程之间不再各自乱序提交;单笔转账比较双方 ID,永远先更新较小者。顺序一致之后,等待关系不可能成环。
- 缩短持锁时间与影响范围(治标但必要):把一切外部调用(HTTP、消息投递、任何有网络等待的操作)全部移出事务;让更新语句走索引以避免扫描多余行导致的更长持有窗口;先在一个事务里做幂等校验并落流水,再更新余额;把”最后是强约束”放到事务的最末一步提交。
- 正确的重试(只做兜底):重试按错误类别分别处理。可重试:死锁(40P01)、序列化失败(40001)——这两类应当整事务重做,并带随机抖动与退避,重试次数设预算(例如不超过 2 到 3 次)。不可重试:唯一约束冲突(23505,说明请求重复或幂等键冲突,应直接返回既有结果)、外键与业务校验失败、语句超时且副作用不明(必须先按幂等键查询真实状态再决定)。重试预算要在客户端层面统计比例,一旦重试占请求的比例超过阈值就快速失败,而不是继续加压。
- 幂等与追踪:每次转账携带业务幂等键,在
ledger上建(幂等键)唯一索引,用它抢占而不是靠”先查后写”(后者在读已提交下存在竞态)。这样即使重试发生,重复入账也被数据库约束拦住。 - 热点行去锁(针对少数超级账户):对高频收款的热账户,改为流水式记账——
INSERT一条不可变的流水行,余额由异步聚合或物化视图更新。写入从”争抢同一行”变成”追加不同行”,冲突面大幅下降,代价是读取侧需要聚合,属于明确的取舍。
三、关键执行流程(改造后的一次转账)
- 入口校验幂等键,并写入请求日志(含业务幂等键、账户对、金额、幂等指纹)。
- 把两个账户 ID 排序,得到确定的更新顺序。
- 开启事务:
INSERT ledger两条流水并依赖唯一索引去重 → 按排序结果依次UPDATE accounts→ 提交。事务内不发起任何外部调用。 - 若返回 40P01 或 40001:释放连接,按
base × 2^n加随机抖动等待后整事务重试,超过预算则向上返回明确的可重试错误。 - 若返回 23505:按幂等键查询既有流水,返回同一结果,不重试。
- 全过程记录锁等待时间、重试次数、事务时长,并按 SQL 语句聚合。
四、技术选型、依据与备选
| 决策点 | 选择 | 依据 | 备选及不选的理由 |
|---|---|---|---|
| 一致性保障 | 全程一致的加锁顺序 | 官方推荐的首要防御手段,且环不存在则死锁不可能发生 | 依赖死锁检测回滚:一是不可预测哪个事务被中止,二是每次都要付出一次失败事务的代价 |
| 重试策略 | 仅死锁与序列化失败可重试,带抖动与预算 | 这两类错误代表”本次尝试未产生任何效果”,安全 | 全错误重试:唯一约束与业务错误重试毫无意义,还会放大负载 |
| 幂等保障 | 业务幂等键 + 唯一索引抢占 | 由数据库约束兜住并发,不依赖应用层时序 | 先查后写:读已提交下两侧同时通过检查,会重复入账 |
| 热点行 | 流水式记账 + 异步聚合 | 把行级争抢改为追加写 | 拆分多行余额(分桶):提升复杂度,且跨桶转账仍需两阶段 |
五、方案取舍
- 全局一致顺序 vs 局部自由:要求所有写路径遵守同一套排序规则会牺牲实现自由度(每个团队写 SQL 时都要记这条规约),换来的死锁不可能发生。折中做法是把”多账户更新”收敛到一个共享的账务写入库,所有人都只调用它。
- 强一致 + 单次事务 vs 流水 + 异步聚合:前者简单且读侧直接,后者吞吐高但引入延迟可见性问题(余额不是实时值)。取舍依据是账户的热点程度:普通账户用事务,超级账户用流水线,而不是全表统一。
- 立即快速失败 vs 重试换成功率:对交互式转账,一次带抖动的受控重试能救回体验;对后台批量,宁可快速失败让调度层稍后重跑,也不要在高峰期与在线流量争连接池。
- 死锁检测时机 vs 锁等待日志:
deadlock_timeout决定等待多久才做一次死锁检查,官方说明该检查”相对昂贵”,默认值为 1 秒,“理想情况下应当超过你的典型事务时长”,并且该值同时决定log_lock_waits何时打印锁等待日志。它调节的是检测延迟与检查开销,不是死锁发生率——把它调大只会让故障更晚被发现,把它调得太小则会在正常抖动下频繁做无用检查。
六、失败路径、边界条件与恢复
- 批量任务与在线事务混合执行:批量按 ID 升序分批推进,每批提交一次并限制批内间隔;提供安全位点,中断后可从位点续跑,避免重跑导致的重复入账。
- 长事务边界:事务中不得发生网络调用、人工等待或大结果集处理;否则看似没死锁的等待会以”连接被占满”的形式表现为全站超时。
- 超时分层:语句级超时(
statement_timeout)用于防止单条 SQL 失控;服务级 deadline 沿调用链传导;二者都要与”客户端已放弃”联动,服务端要能感知并中止已无人等待的事务。 - 幂等:所有写操作携带业务幂等键,靠唯一索引抢占。重试前先查真实状态(“幂等键已存在”意味着要返回原结果而不是报错)。
- 恢复:死锁被回滚的事务不产生任何副作用,可以安全整事务重做;但被回滚的那一方是不可预测的,不应依赖——所以业务层不能假设”总是对方失败”。
七、性能与可用性
- 连接池:这是本案被忽略的关键资源。连接池容量应与控制数据库的并发写者数量绑定,而不是随意配到 CPU 核数的倍数;一旦全部连接被锁等待占满,健康的短请求也会被拖死。治理手段包括:读写分离(把长查询与写分离到不同池)、按操作类型拆池、并对批量任务单独设置较小的池上限,给在线流量留出余量。
- 索引与执行计划:缺失索引会让更新扫描更多行并在途中持有更长的时间,冲突窗口随之放大;同时要注意更新行数越多,单事务时长越长。
- 可用性指标:锁等待时间 P99、死锁发生频率(按 SQL 指纹分组)、事务平均与 P99 时长、连接池占用率与等待队列长度、重试占比、每类错误的返回比例。
八、验证指标与上线方式
- 离线复现:本地开两个会话,按相反顺序更新同一对行,观察其中一个被中止;再按一致顺序执行,确认不再出现。
- 压测:在预发布环境压到 3000 TPS 并混入批量任务,对比改造前后的死锁率、P99 延迟与连接池占用。
- 上线观测:打开
log_lock_waits以捕获超过阈值的锁等待;按 SQL 指纹统计死锁来源;把”重试占比”做成看板指标,一旦超过阈值就告警——重试占比本身就是一个健康度信号,而不是一个无害的实现细节。
九、面试官追问(逐渐深入)
- 追问:为什么自动重试会让故障窗口从几秒变成几分钟?
回答要点:三层放大——同一组合的锁请求在同一时刻重复出现,环形等待概率不降反升;等待期间连接不释放,连接池被占满后把所有健康请求一起拖慢;单次请求耗时变成”尝试次数 × 等待到检测出死锁的时间”,尾延迟随之成倍放大。缺少随机抖动会让重试在时间上对齐,这正是放大器的电源。 - 追问:是不是所有报错都适合重试?
回答要点:不是。只有”本次尝试确定未产生任何效果”的错误才可重试——死锁(40P01)与序列化失败(40001)属于这一类;唯一约束冲突(23505)说明请求已经处理过或重复提交,应当查询并幂等返回;业务校验失败与超时且副作用不明的请求,必须先按幂等键确认真实状态再决定,盲目重试可能造成重复入账。
十、30 至 60 分钟小型实践
用两个数据库会话做一次对照实验:
- 会话 A 更新账户 1 再更新账户 2,会话 B 反过来执行,观察其中一个会话报出死锁并被回滚,注意每次被回滚的一方可能不同。
- 把双方改成”先更新 ID 较小者”,重跑同样的并发步骤,确认死锁消失。
- 加上
log_lock_waits,把deadlock_timeout临时调小,观察锁等待日志何时被打印出来,体会”检测时机”与”发生概率”是两件不同的事。 - 最后在同一个
ledger上建唯一索引,用同一幂等键并发提交两次,确认第二次是唯一的报错而不是重复入账。
关键点
- 死锁是应用侧加锁顺序的产物;官方给出的首要防御是”所有应用以一致的顺序获取多个对象上的锁”。
- 行级锁足以成环,不需要任何显式加锁语句;被中止的那一方不可预测,业务不得依赖。
- 未判定为死锁时,等待是无界的——事务不能长期保持打开,尤其不能在其中等待外部调用。
- 只有”本次尝试确定未产生任何效果”的错误才可重试(40P01、40001),唯一约束冲突要幂等返回而非重试。
- 连接池是被低估的关键资源:等待中的事务占住连接,会把局部冲突放大成全站超时。
常见误区
- 把死锁当成数据库要背的锅:官方明确”即使不使用显式加锁,行级锁也会造成死锁”。死锁是应用侧的锁序问题,数据库只是在事后打破环形等待,而且被中止的那一方不可预测。
- 把重试当成解决方案:重试不修根因,只把失败改写成”更慢的失败”。没有统一锁序、没有抖动、没有预算的重试,是把局部故障扩散为全局故障的标准做法。
- 用调大
deadlock_timeout来”减少死锁”:它只决定检测到死锁前等待多久(默认 1 秒),不改变死锁发生的概率;调大还会推迟故障发现,并影响锁等待日志的可观测性。
延伸学习
- PostgreSQL 官方文档(当前版 18)13.3 显式锁定:死锁章节的官方建议与行级锁成环示例。已核验。
- PostgreSQL 官方文档 19.12 锁管理中
deadlock_timeout条目:默认 1 秒、检测开销与”理想值应超过典型事务时长”的说明。已核验。
Q-20261023-L01 · LeetCode
- 题目 ID:Q-20261023-L01
- 一级类型:回溯(包含在 LeetCode 类型列表的「回溯和贪心」范围内)
- 二级主题:可重复选择的组合搜索、起点控制与排序剪枝
- 难度:medium
题目
给你一个无重复元素的整数数组 candidates 和一个目标整数 target,找出 candidates 中可以使数字和为目标数 target 的所有不同组合,并以列表形式返回,可以按任意顺序返回答案。
candidates 中的同一个数字可以无限制重复被选取。如果至少一个数字的被选数量不同,则两种组合是不同的。
约束:1 <= candidates.length <= 30;2 <= candidates[i] <= 40;candidates 的所有元素互不相同;1 <= target <= 40。测试用例保证和为 target 的不同组合数少于 150 个。
先思考
- 先想递归函数的状态需要包含什么:已经选了什么、接下来还能从哪个位置开始选、以及还剩多少额度。
- 再想”同一个数字可以重复选”这件事,在递归时应当体现为下一次还能不能回到当前位置——试着把这个”起点”作为一个参数。
- 然后想为什么”先排序”会让递归变快:如果候选是按升序排的,那么在某个分支上一旦遇到超过剩余额度的数,后面的数还需要试吗。
- 最后确认一次输出规模:题目要求返回所有组合,那么渐进复杂度能不能低于输出的总规模。
参考答案
元数据
| 项 | 内容 |
|---|---|
| 题号 | 39 |
| 中文名 | 组合总和 |
| 英文名 | Combination Sum |
| 难度 | Medium(LeetCode 官方标注;本次核验时英文页与中文页均未渲染难度标签,仅题号、标题、示例与约束可核验一致) |
| 中文官方文档 | https://leetcode.cn/problems/combination-sum/ |
| 英文官方文档 | https://leetcode.com/problems/combination-sum/ |
| 核验结果 | 中英文两页的题号、标题、三个示例与四条约束完全一致;英文页显示通过率约 77.3%(该值会随时间变化) |
输入与输出:输入为一个互不相同的整数数组与一个目标整数;输出为一个列表,其中每个元素是一个和为 target 的组合,组合之间互不相同。
约束:1 <= candidates.length <= 30;2 <= candidates[i] <= 40;元素互不相同;1 <= target <= 40;保证解的数目少于 150。
示例
| 示例 | 输入 | 输出 | 说明 |
|---|---|---|---|
| 1 | candidates = [2,3,6,7], target = 7 | [[2,2,3],[7]] | 2 可以重复使用;官方原样输出 [2,2,3] 与 [7] |
| 2 | candidates = [2,3,5], target = 8 | [[2,2,2,2],[2,3,3],[3,5]] | 三种组合,元素可按任意顺序返回 |
| 3 | candidates = [2], target = 1 | [] | 目标小于所有候选,无解 |
直观方案及其局限:最直接的写法是”对每个元素做选与不选的二叉决策”。这条路能走通,但它把允许重复选取的语义混进了分支:同一条路径上可以先选 2、不选 3,也可以在另一条路径上先不选 2、再选 3,导致 [2,3] 与 [3,2] 这类仅顺序不同的组合被重复枚举,需要额外的集合去重。更好的做法是直接用”层”来表达选择:每一层只从当前位置往后选,天然去掉了顺序带来的重复。
推荐方案的推导过程:把搜索组织成树,第 depth 层负责在 candidates 中选择第 depth 个元素。既然允许同一个数字被重复选取,那么选了 candidates[i] 之后,下一层仍然可以从 i 开始——这就是递归时 dfs(i, remain - c) 而不是 dfs(i + 1, ...) 的原因;同时为了保证组合不被重复枚举,下一层只能从 i 往后看,range(start, len(candidates)) 的起点就承担了这个约束。最后把 candidates 升序排列:某一层遍历到第一个超过剩余额度的候选时,由于后面的候选更大,也一定超过剩余额度,可以整层提前终止。
正确性说明:三句话。其一,算法的搜索树中每一条从根到和为 0 的路径都对应一个合法组合:每一步减少的都是某个候选值,减到恰好为 0 时所有被减数的和就是 target。其二,起点单调不减保证了输出的组合按候选下标是非递减的,因此同一个多重集合只会被产生一次(每个多重集合有唯一的非递减表示),既无重复也无遗漏。其三,排序后的提前终止只砍掉”当前余额已经装不下该候选及其之后所有候选”的分支,而这些分支不可能产生和恰为 remain 的组合,因此不影响结果的完备性。
时间复杂度:输出规模本身就是答案的总元素个数,因此时间下界是答案规模。设解的数量为 R、每个解的平均长度为 L,时间复杂度为 O(R × L) 量级上的搜索节点数;相对于常见的估算方式可表述为 O(S),其中 S 为搜索树节点总数,最坏情况下随 target 与候选个数增长而指数级膨胀,本题因 target <= 40 且 combos 数量受限而可控。
空间复杂度:O(T) 的递归栈深度加上输出结果所占空间,其中 T 为单个组合的最大长度(上界约为 target / min(candidates));不计算输出时为 O(T)。
Python 参考代码(已在本地 Python 3.13 执行,10 个用例与暴力枚举解交叉验证全部一致)
from typing import List
class Solution: def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]: candidates.sort() res: List[List[int]] = [] path: List[int] = []
def dfs(start: int, remain: int) -> None: if remain == 0: res.append(path.copy()) # 必须拷贝,path 会被复用 return for i in range(start, len(candidates)): c = candidates[i] if c > remain: # 已排序,后面的候选只会更大 break path.append(c) dfs(i, remain - c) # start = i:同一个数字可以重复选 path.pop()
dfs(0, target) return res边界情况
remain == 0:立即收集结果并返回,不要继续向下递归,否则会进入负数分支。path必须拷贝后再加入答案:res.append(path.copy()),直接append(path)会因为后续pop而得到一堆空列表。- 输入的
candidates未必有序,必须先sort(),否则break剪枝不成立。 - 单个候选值等于
target时应当被收录([[7]]);所有候选都大于target时返回空列表。 - 题目保证元素互不相同,所以不需要处理同层去重的额外逻辑——这是本题与 40 题(组合总和 II)的关键差别。
测试用例(均为本地实际执行结果)
| 编号 | candidates | target | 期望输出 | 实际结果 |
|---|---|---|---|---|
| 1(官方示例 1) | [2,3,6,7] | 7 | [[2,2,3],[7]] | 一致 |
| 2(官方示例 2) | [2,3,5] | 8 | [[2,2,2,2],[2,3,3],[3,5]] | 一致 |
| 3(官方示例 3) | [2] | 1 | [] | 一致 |
| 4(输入未排序) | [3,2,6,7] | 7 | [[2,2,3],[7]] | 一致,验证了必须先排序 |
| 5(最小候选等于 target) | [40] | 40 | [[40]] | 一致 |
补充:另有 5 个用例([2],2、[5,10,8,4,3],11、[7,3,2],18、[2,4,6],5、[9,11],20)也与暴力枚举解逐项比对一致。
面试官可能的变体或追问:如果把”同一个数字可以无限制重复选取”改成”每个数字只能用一次”,改动在哪里?答案是把递归调用改为 dfs(i + 1, remain - c),让下一层从下一个位置开始;如果此时 candidates 中还可能出现重复元素(40 题),则还需要在同一层里跳过值相同的相邻候选,避免同一层的重复选择产生相同组合。这两处改动分别对应”是否允许重复选取”与”输入是否含重复”两个正交维度。
可迁移的解题能力:把搜索组织成”层 + 起点”的形式,用一个单调不减的起点参数消除排列层面的重复;用排序把”提前结束”变成合法剪枝;以及在面对”要求输出全部解”的问题时,先承认输出规模就是复杂度的下界,再去优化每个节点上的开销。最后一条尤其重要:先判断复杂度的地板在哪里,避免在不可能优化的方向上浪费时间。
与 AI Agent 工程的关系(自然联系才写):Agent 在枚举候选计划、工具组合或子任务分解时同样面临组合爆炸,可靠做法与本题一致——在进入子树之前就用约束剪枝,而不是走到叶子才发现不可行。今天 S01 里编排层的”步数与 token 预算”就是这个思路在系统层面的版本:在入口或分支点拦住,而不是让请求一路排队到底再被超时回收。
关键点
dfs(i, ...)而不是dfs(i + 1, ...):允许重复选取当前元素。range(start, n)的单调起点:保证同一个多重集合只被枚举一次。- 先排序才能用
break剪枝;输入无序时这是必须的预处理。 - 收集答案时要拷贝路径;
remain == 0要立即返回。 - 输出规模本身就是复杂度下界,不要用”能否优化到多项式”去为难自己。
常见误区
- 写成”选与不选”的二叉分支却不处理顺序重复:会把
[2,3]和[3,2]当作两个解。用层 + 起点的写法从结构上消除该问题,而不是最后套一层集合去重。 - 忘记排序就
break:未排序时遇到大于余额的元素并不代表后面没有可行候选,剪枝会直接砍掉正解,产生静默的错误答案。 - 把
path直接放进答案列表:后续回溯的pop会把已保存的结果清空,最终得到若干个空列表或错误的重复列表。
延伸学习
- LeetCode 官方页面(中英文均已核验可访问):https://leetcode.com/problems/combination-sum/ 与 https://leetcode.cn/problems/combination-sum/
- 同骨架练习方向(未核验页面内容,仅作延伸):40 组合总和 II(每个元素仅用一次且输入含重复)、77 组合、216 组合总和 III——三者分别对应”输入含重复”与”长度固定”两个维度的变化。
延伸资料
| 主题 | 资料 | 类型 | 核验状态 |
|---|---|---|---|
| 泛型与类型约束 | 《An Introduction To Generics》,Robert Griesemer 与 Ian Lance Taylor,2022-03-22,go.dev/blog/intro-generics | 官方博客 | 已核验(原文含”类型约束必须是接口""操作须为类型集合中所有类型所允许”) |
| 查询改写 / HyDE | arXiv:2212.10496《Precise Zero-Shot Dense Retrieval without Relevance Labels》,Luyu Gao、Xueguang Ma、Jimmy Lin、Jamie Callan,2022-12-20 提交,cs.IR | 原始论文 | 已核验(标题、作者、提交日期、摘要原文) |
| LLM 推理显存与吞吐 | arXiv:2309.06180《Efficient Memory Management for Large Language Model Serving with PagedAttention》,Kwon 等,2023-09-12 提交,SOSP 2023 | 原始论文 | 已核验(摘要原文:近零显存浪费、吞吐 2 到 4 倍) |
| 过载、准入与重试预算 | Google SRE Book 第 21 章《Handling Overload》,sre.google/sre-book/handling-overload/ | 官方工程著作 | 已核验(关键句:按关键性拒绝、每客户配额、客户端自适应限流、重试预算;该章未提及 Little’s Law,也未把队列深度作为丢弃信号,勿据此引用) |
| 死锁与锁顺序 | PostgreSQL 官方文档(当前版 18)13.3 显式锁定,www.postgresql.org/docs/current/explicit-locking.html | 官方文档 | 已核验(“防范死锁的最佳方式通常是避免它们……以一致的顺序获取多个对象上的锁”;行级锁也会造成死锁;未检出时等待是无限的;被中止的事务不可预测) |
| 死锁检测时机 | PostgreSQL 官方文档 19.12 锁管理 deadlock_timeout,www.postgresql.org/docs/current/runtime-config-locks.html | 官方文档 | 已核验(默认 1 秒;检测开销高故延后检查;“理想值应超过典型事务时长”;同时决定 log_lock_waits 输出时机) |
| 回溯与剪枝 | LeetCode 39 中英文官方题目页 | 官方文档 | 已核验(题号、标题、三示例、四约束一致;难度标签未在抓取结果中渲染) |
未核验项(不作为结论引用):Oracle Java 教程《Type Erasure》具体页面内容;Spanner TrueTime 的工程落地数值;各推理框架默认的连续批处理参数;LeetCode 40 / 77 / 216 的页面正文。
当日重点
- 型变与静态类型的边界(B01):读写双通道的容器必须不变;擦除型泛型运行时拿不到实参类型,反序列化必须显式传入类型令牌或判别字段;静态类型只覆盖编译期与单进程。
- Query Rewrite 的作用位置(B02):改写只改变检索输入,主要影响召回;标识符类查询禁止自由改写;多轮实体绑定是确定性工作,不应交给模型。
- 容量规划的核心误判(S01):GPU 利用率是时间占比,不是排队信号;并发上界由 KV Cache 显存决定;会话到模型调用的放大倍率是重尾的,必须用预算约束;过载要在入口拦,而不是让它排队到超时。
- 死锁是应用侧锁序问题(S02):一致性加锁顺序是首要防御;行级锁即可成环;重试只是兜底,必须带抖动与预算,并且连接池容量是本案被忽略的关键资源。
- 回溯的结构化写法(L01):层 + 单调起点的写法从结构上消除排列重复;排序是
break剪枝的前提;输出规模即复杂度下界。
本次运行信息(不随消息发送)
配置读取状态:成功。题库配置.md 版本 6(last_maintained: 2026-10-19,mtime 仍为 10-19 19:51,本次未修改)。本次按 v6 的 5 题配额执行:非 Agent 基础 1、Agent 基础 1、Agent 场景 1、非 Agent 场景 1、LeetCode 1。无暂停类型;「观察中的新技术」8 项均未标记「允许试学」,本次未从中出题。
本次参考的历史题目来源:根目录 题目索引.md(60 条,indexed_runs: 10)、精选/README.md(4 条)。未读取窗口外的历史正文,未修改任何历史训练文件。
去重结果:
- 与最近 30 天(2026-09-23 至 2026-10-22)的题目做了标题、一级类型、二级主题、核心考点、预期结论、场景约束与 LeetCode 题号的多维比对,无重复、无换皮题。
- 本次 B01 选用「编程语言与运行时」下的类型系统与泛型(索引中标注为缺口,历史未被覆盖)。
- 本次 B02 选用「RAG 与搜索」下的 Query Rewrite(历史 RAG 题为混合检索融合、切片策略、知识更新溯源,主题不重合)。
- 本次 S01 首次使用场景题类型「部署、扩缩容和容量规划」。
- 本次 S02 首次在非 Agent 场景覆盖「死锁与锁序」。
- LeetCode 已用题号:146、3、215、739、875、124、207、142、547、560,本次新增 39(回溯)。已规避 322(历史已删除未登记,仍按 30 天去重口径规避)。本次算法模式为回溯,与此前的前缀和/哈希、并查集、图论、链表等模式均不重复。
到期精选提醒:Q-20261015-B03 的建议复习日为 2026-10-23(今日到期),角度为「评测体系设计」;Q-20261015-S01 建议复习日 2026-10-24,角度为「越权工具调用治理」。本次为每日任务,未自动执行复习或精选动作。
本次建议复习日:
| 题目 ID | 建议复习日 | 转换角度 |
|---|---|---|
| Q-20261023-B01 | 2026-10-27 | 从”型变与边界”转为”线上事故定位”:给定一个反序列化后类型错配导致资损的案例,要求定位责任环节并给出补救方案 |
| Q-20261023-B02 | 2026-10-28 | 从”定义与适用边界”转为”评测设计”:给定改写前后 recall@10 上升、答案正确率下降的指标,要求分层归因并给出开关策略 |
| Q-20261023-S01 | 2026-10-30 | 从”容量与准入”转为”成本治理”:给定单位成本上涨 40% 而 QPS 不变的指标,要求按租户与任务类型归因并给出节流方案 |
| Q-20261023-S02 | 2026-10-30 | 从”死锁与锁序”转为”连接池容量治理”:给定连接池打满的故障,要求推导容量上限并设计按操作类型拆池的方案 |
周期复盘:本次为累计第 12 次运行。上一份周复盘为 2026-10/2026-10-19-AI-Agent-周复盘.md(文件存在)。按每 7 次一次的节奏,下次周复盘预计在 2026-10-26 前后。本次未触发。
远端同步状态:本文件已成功写入本地 Obsidian 目录,未对远端同步做任何验证。