面试知识库

ShoppingX 模拟面试问答集#

定位:大厂校招 AI/LLM 应用工程师,难度从基础到高级递进。 原则:每个回答以代码实现为准;若非最优则补最优方案;每题附追问链。 记忆技巧:每个回答按「结论先行 → 关键细节 → 生产经验」三段式组织。

目录#

模块题号核心考点
一、Agent 架构Q1-Q4(含 Q1.1)主循环设计、意图驱动 vs 固定 workflow、同质 fork、并发控制、机制 vs prompt
二、工具体系Q5-Q7LLM vs 规则分类、渐进式数据流、语义缓存
三、向量召回Q8-Q9单编码器 + payload filter、数据质量坑
四、上下文压缩Q10Cache Breakpoint、CJK token 计数
五、长期记忆Q11–Q12偏好存储/去重/域隔离读取、授权分档(谁能硬淘汰)、行为亲和(收藏→弱加分,唯一不经 LLM 的证据)
六、实时通信Q12-Q13connect-first 协议、WS vs SSE、任务排队与取消
七、工程基础设施Q14-Q16熔断器、FinOps 预算闸、JWT 鉴权
八、可观测性Q17AGUI 事件流、结构化日志、Langfuse
九、数据工程Q18ETL 管线、平台多态、科学计数法坑
十、评测体系Q19-Q19.3Rubric 三级评分、零 GPU 迭代闭环、打分链路全流程、good/bad 分流、judge 两刀
十一、系统设计Q20-Q22架构反思、性能优化、诚实边界
十二、快速问答Q23-Q26asyncio 基础、ContextVar、Pydantic、lru_cache
十三、进阶亮点Q27-Q32ask_user Future 桥接、汇率、reranker 短路、前端状态、测试策略、背压分层
十四、系统设计开放题Q33-Q34扩容方案、项目总结
十五、新增能力Q35-Q38图搜选购、Bundle 跨品类组合、credit 配额、账户归属

一、Agent 架构(核心必问区)#

Q1:你的 Agent 主循环是怎么设计的?和 ReAct 有什么本质区别?#

回答:

主循环是 Think → Act → Observe → Reflect 四阶段有状态循环,基于 LangGraph 的 create_react_agent 二次封装。和标准 ReAct 最核心的区别有两点:

  1. 不写死步数,模型自判终止。模型在 Reflect 阶段认为信息够了,就调终结性工具收尾(shopping_summarychat_fallback),循环自动退出。代码里 TERMINAL_TOOLS = {"shopping_summary", "chat_fallback"},这两个工具一旦被调用,LangGraph 就不再调度下一轮。

  2. 四层机制兜底,不靠 prompt 祈祷。实测弱模型(flash 系列)经常不收尾——prompt 里写了”请及时终止”也没用。所以我做了机制级防护:

    • ① 深度闸MAX_FORK_DEPTH=1,子 Agent 不能再 fork(fork_guard.py 里 ContextVar 跟踪,超限直接抛 ForkLimitExceeded
    • ② 超时 + 迭代上限:主循环 MAIN_AGENT_TIMEOUT_SEC=300sMAIN_AGENT_MAX_ITERATIONS=30;子 Agent 更紧 SUB_AGENT_TIMEOUT_SEC=90sSUB_AGENT_MAX_ITERATIONS=6
    • ③ 工具结果截断:每次工具返回最多 MAX_TOOL_RESULT_TOKENS=4000 token(harness/truncation.pytruncate_tool_result 函数),防上下文窗口爆炸
    • ④ 循环检测LoopDetector(window=6, threshold=4)harness/loop_detector.py),滑动窗口 6 次调用里出现 4 次同一工具就注入”换策略”提示

还有个不明显但很重要的细节:system prompt 里 <termination> 规则放在末尾。这是实测调出来的——LLM 对上下文末尾的注意力权重最高,终止规则放开头容易被中间的工具结果”稀释”。

追问 1:你说的迭代上限 MAX_ITERATIONS=30,LangGraph 的 recursion_limit 是怎么算的?

recursion_limit = MAX_ITERATIONS * 2 + 1,因为 LangGraph 的每个”迭代”实际包含两个 super-step(一次模型调用 + 一次工具执行),再加 1 是留给最终响应的。代码在 main_agent.py 里:MAIN_AGENT_RECURSION_LIMIT = MAIN_AGENT_MAX_ITERATIONS * 2 + 1。但实践中真正逼模型收尾的不是 recursion_limit 硬墙——它只是最后兜底。日常管住循环的是 harness 里的看门狗watchdog.py):检索预算耗尽 + LoopDetector 打转时,看门狗直接在 awrap_model_call 里合成收尾消息、置 terminal_reached不再唤模型——比等 recursion_limit 撞墙优雅得多。

追问 2:循环检测到之后是直接中断还是?

不是硬中断,是软提示LoopDetector.nudge_message() 返回一段中文提示注入到工具结果末尾,建议模型”换一个策略或直接调 shopping_summary 收尾”。为什么不硬中断?因为合理场景下同一个工具可能需要多次调用——比如 5 个平台各搜一次 item_search 就是 5 次,不是循环。滑动窗口比硬次数限制更精准。

追问 3:如果模型软提示了还不收尾呢?

那就走到迭代上限或超时,自然被强制终止。多层防护的思路就是——软的不行有硬的兜底,但尽量先软引导,给模型改正机会。

当前实现 vs 最优方案: 当前方案已经是业界主流做法。如果要进一步优化,可以加一个 token 累计触发器——当单次会话累计消耗 token 超过阈值时强制收尾,而不只是看迭代次数。项目里 token_budget.pybudget_status() 已经实现了这个能力(soft/hard 两级),在 hard 级别时会从模型视野里移除高成本工具(COST_AMPLIFIER_TOOLS),间接逼模型收尾。这比直接中断更优雅。


Q1.1:你这套流程是不是把步骤规定得太死了?我只想让它推荐几个商品,它却比价、算关税、算运费全走一遍——这不就是披着 loop 外壳的 workflow 吗?#

回答:

这个质疑砍中了一半,我先认下对的那一半,再说为什么它不是伪 loop,最后讲我怎么改的。

先认账:早期版本里,system prompt 有一段”标准购物流程”,把工具次序写成 planner → 跨平台检索 → price_compare → shipping_calc → item_picker → shopping_summary 的 1→7 链,其中比价、算运费是无条件列进去的。所以只要判成购物意图,哪怕用户只说”推荐几个”,模型也会顺着链把比价和到手价都跑一遍。这确实是过度规定。

但它不是”披着 loop 的 workflow”,关键区别在于——流程次序没有一行代码强制

  • 真正的 workflow 是 r = search(); c = compare(r); s = ship(c); pick(s),次序焊死在代码里。
  • 我这里是 create_agent(FULL_TOOL_SET) + agent.ainvoke(),就是个 ReAct 工具循环。第 4/5 步跑不跑、什么顺序跑,全是模型自己选的工具调用,代码层面它完全可以跳过 price_compare 直接 item_picker,没有任何 assert 拦它。
  • 而且顶层本来就有真分支:问品类行情走 category_insight → chat_fallback、问外部事实走 web_search → chat_fallback、闲聊走 chat_fallback——这些是模型在做意图判断、选不同的链,workflow 做不到。

所以准确定性是:它坐在”纯 workflow”和”纯 agent”之间的光谱上——意图路由层是真 agent,购物意图内部曾经偏 workflow。面试官说”购物这条链自由度太低”是对的,说”整个是伪 loop”是错的。

改造:把约束从”一条叙事链”迁移成”意图信号 + 机制兜底”,分三步:

  1. planner 从字段抽取器升级成意图路由器——多输出 tasksrecommend/evaluate/price_compare/landed_cost/category_intel)和 target_refs(用户点名的具体商品)两个信号。
  2. prompt 从”一条链”改成”能力菜单”——主循环按 tasks 组合能力,只做用户要的:只说”推荐”就 检索 → item_picker → shopping_summary跳过比价和运费;说”哪个到手最划算”才把 price_compare/shipping_calc 加回来。
  3. 机制里的固定链一并拆掉——这是最容易被忽略的一点,见追问 1。

改完之后,你那个原始场景(“只想推荐”)现在就只推荐,不再自作主张比价算关税。

追问 1:你说”链焊在代码里”,可 prompt 是软约束、模型本来就能不听,改 prompt 不就够了吗?为什么还要动代码?

因为那条”必比价 + 算运费”的链不只在 prompt,还焊进了 middleware.py 的 5 条哨兵——这是我改造时才挖出来的。这些哨兵是”逼收尾”的安全兜底(检索预算耗尽、fork 用满、token 超限时触发),每一条的文案都在命令模型”依次 price_compare → shipping_calc → item_picker → shopping_summary 收尾”:_converge_directive_retrieval_exhausted_FORK_EXHAUSTED_MAIN_POSTFORK_SEARCH_DENIED_BUDGET_HARD_DENIED。只改 prompt、不改这 5 条,模型一旦撞到预算闸就又被硬拽回全链。

但我没有把 tasks 塞进 middleware。因为哨兵的职责是”反死循环 / 反 token 爆炸”,不是”强制哪一步跑”。所以我只把文案从”必四步”泛化成”按你的任务继续收尾(要比价 / 到手价才 price_compare / shipping_calc)“——middleware 的结构、判定逻辑一行没动,“跳过比价”这个决策仍然交给 prompt + tasks 由模型做。机制管失控,不管品味,这条边界要守住。

追问 2:那”评价某商品好不好”算聊天还是购物?它要不要走这套购物流程?

它是混合体——评价既要给一段文字结论(chat 味),又该顺带推几张相关/更优的商品卡(shopping 味)。我定的分水岭是「产出里有没有商品卡」:有卡(推荐清单 / 评价对象 + 推荐替代品 / 比价结果)就走 shopping_summary(它天生能装文案 + items),纯文字(品类行情 / 外部知识 / 闲聊)走 chat_fallback。所以 evaluate 走 shopping_summary:文案放”相对品类基准算不算好 + 命中/违反你偏好”的判断,picks 放”被评商品 + 推荐替代品”。评价能力我没新建工具,用现有的组合实现:category_insight 拿评分大盘 + 维度基准(这是”好坏”的基准线)、候选自身 rating、需要口碑再 web_search

追问 3:放开自由度,会不会又把你当初压住的”不收尾死循环 / 乱 fork / token 爆炸”放回来?

不会,因为安全闸和流程是正交的。我放开的是”该跑哪些可选步骤”,没动”能不能失控”——fork 深度闸、fork 预算(跨平台只放行一轮)、检索总量预算(全树封顶 8 次)、超时 + 迭代上限、循环检测、token 硬线,这六层原封不动。它们不关心你比不比价,只关心你有没有打转、烧没烧超预算。所以”轻推荐”反而更省——少跑两个工具,撞闸的概率更低。

当前实现 vs 最优方案: 现在 middleware 不感知 tasks,靠”泛化文案 + 模型自觉”跳过可选步骤,偶发方差下模型仍可能多跑一步比价。更彻底的做法是让 tasks 也驱动机制——比如 target_refs 非空(定点比较)时,直接在 _ensure_platform_coverage 里跳过”补齐 5 平台”的机制(目前它靠”demand 里没有平台名”来天然规避,够用但不够显式)。再进一步可做 tasks 感知的 rubric 权重——评测时按用户意图动态调各维度权重,而不只是”没要比价就不扣分”。这些属于精细化,当前意图信号 + 泛化哨兵的组合已经把”披着 loop 的 workflow”这个核心问题解掉了。


Q2:什么是”同质 fork”?子 Agent 和父 Agent 有什么相同和不同?#

回答:

同质 fork 是指子 Agent 和父 Agent 是完整克隆——共享相同的工具集(FULL_TOOL_SET)、相同的 system prompt、相同的 middleware 栈。对父 Agent 来说,fork 就是”另一个工具”——通过 dispatch_tool(demands) 调用,传需求文本、拿最终回复。

相同点:

  • 工具集:tool_registry.pyFULL_TOOL_SET 是同一个 list 引用,make_dispatch_tools(lambda: FULL_TOOL_SET) 通过延迟求值保证子 Agent 创建时拿到的是完整列表(含 dispatch_tool 自身)
  • System prompt:同一份 get_system_prompt() 输出
  • Middleware:每次 fork 都 build_agent_middleware() 创建新实例(LoopDetector 和计数器独立)

不同点:

  • LLM 不同:子 Agent 用 get_fast_llm()(关闭推理/reasoning),父用 get_llm()。因为子任务通常只需要 1-2 次 item_search,不需要深度推理,关掉 reasoning 能省 96% 的解码时间
  • thread_id 不同:子 Agent 生成独立的 sub-{uuid[:8]}-d{depth},隔离对话历史
  • session_dir 相同:继承父的 session_dir,产物归同一个会话目录
  • 可见工具被裁剪:虽然 FULL_TOOL_SET 相同,但 harness 中间件(agent_middleware.py)会在 depth≥1 时拦截以下工具的调用:
    • DEPTH0_ONLY_TOOLS = {"price_compare", "shipping_calc", "item_picker", "shopping_summary"} — 聚合/终结工具,只有主 Agent 能调
    • MAIN_ONLY_CONTEXT_TOOLS = {"planner", "category_insight"} — 平台无关的全局上下文工具,算一次就够
  • 检索预算更紧SUB_ITEM_SEARCH_CAP=1,子 Agent 每次 fork 只能搜 1 次 item_search

追问 1:为什么不给子 Agent 定制专用工具集,而用”全给但拦截”的方式?

三个原因:

  1. 维护一份不维护 N 份——工具集只有一个 FULL_TOOL_SET,不存在”忘了给子 Agent 加新工具”的问题
  2. 保 prompt cache 前缀——如果子 Agent 的工具列表和父不同,前缀就变了、缓存打不中(跨轮缓存治理见 M6.1
  3. 实际拦截在 harness 执行层——被拦截时返回的 sentinel message 会告诉模型”这个工具在子 Agent 里不可用,请把结果返回给主 Agent 处理”,模型能理解并正确行动

项目试过”从 request.tools 里移除被禁工具”这条更激进的路、又撤了:移除工具会打断 prompt cache 前缀。所以当前代码所有禁用统一走执行层哨兵、工具表恒定——被禁工具照常在表里,模型调了就回一条 sentinel 消息拦下。取舍是「哨兵拦执行不拦 decode(模型仍花一次解码)vs 摘工具破缓存前缀」,选了保缓存。

追问 2:lambda: FULL_TOOL_SET 这个延迟求值为什么重要?

因为 dispatch_toolparallel_dispatch_tool 自身也在 FULL_TOOL_SET 里。如果在模块加载时直接引用 FULL_TOOL_SET,那时候 dispatch_tool 还没创建、还没 append 进去,子 Agent 就拿不到 fork 工具(虽然 depth=1 时 fork 会被深度闸拦截,但工具描述的完整性很重要)。lambda 把求值推迟到子 Agent 实际创建时,此时列表已经完整。

追问 3:子 Agent 的异常会影响父 Agent 吗?

不会。_run_sub_agent()所有异常都被 catch 并转为字符串返回:

except ForkLimitExceeded as e: return f"[dispatch_tool 拒绝] {e}"
except TimeoutError: return f"[dispatch_tool 超时] ..."
except GraphRecursionError: return "[dispatch_tool 拒绝] 子任务迭代超限"
except Exception as e: return f"[dispatch_tool 错误] {type(e).__name__}: {e}"
python

对父 Agent 来说,它只看到一个工具返回了一段包含错误信息的文本,可以据此决定是重试还是放弃——不会 crash。


Q3:fork 的并发控制是怎么做的?如果同时 fork 出 5 个子 Agent,怎么防止资源爆炸?#

回答:

并发控制有三层,分别管不同维度:

第 1 层:fork 次数预算(ForkBudget

harness/budgets.py 里的 ForkBudget 类控制 fork 调用次数:

  • max_parallel=1parallel_dispatch_tool 最多调 1 次(一次并行 fork 已经覆盖所有平台)
  • max_serial=4dispatch_tool 在 parallel 之前最多 4 次
  • 语义约束:parallel 调过之后不允许再 serial——因为 parallel 已经全平台覆盖了,再 serial 就是浪费

通过 fork_budget_scope() 上下文管理器在 run_agent() 入口创建,ContextVar 传播给整棵调用树。

第 2 层:并发度信号量(fork_concurrency_scope

harness/budgets.py 里的 asyncio.Semaphore(FORK_CONCURRENCY=5) 限制同时执行的子 Agent 数量。parallel_dispatch_toolasyncio.gather 并发启动多个子任务,但每个子任务在执行前要 async with fork_sem 获取信号量——超出 5 个的排队等待,不是拒绝

这和第 1 层的区别是:第 1 层管”总共能 fork 几次”,第 2 层管”同时跑几个”。

第 3 层:检索预算(agent/retrieval_budget.py

树级别的全局检索计数。主 Agent + 所有子 Agent 共享同一个计数器(通过 session_dir 作为 dict key,不用 ContextVar——因为 ContextVar 子设父读不到,dict 可以):

  • TREE_RETRIEVAL_BUDGET=8:整棵调用树总共最多 8 次 item_search + web_search
  • SUB_ITEM_SEARCH_CAP=1:单个子 Agent 最多 1 次 item_search
  • 超预算后在执行层回哨兵拦下 item_search(harness tool_gates.py 判定),工具照常在表里但不执行——不摘工具以保 prompt cache 前缀(见 M6.1

追问 1:为什么检索预算用 module-level dict 而不是 ContextVar?

ContextVar 的继承是单向的——父 set 的值子能看到,但子 set 了新值不会回写给父。如果用 ContextVar 做计数器,子 Agent increment 了一次,父 Agent 看不到变化,总数就对不上。所以改用 _STATE: dict[str, _TreeRetrieval],key 是 session_dir(字符串),main 和所有 children 的 session_dir 相同(子继承父的),所以读写的是同一个 dict entry。这是实际开发中发现 ContextVar 不适合”跨 task 聚合”场景后改的。

追问 2:信号量排队会不会导致整体超时?

会。但这正是期望行为——宁可排队慢一点,也不要同时打爆下游服务。而且信号量等待时间不算在子 Agent 的 SUB_AGENT_TIMEOUT_SEC=90s——代码里先 async with fork_sem(排队),然后才 asyncio.wait_for(..., timeout=90)(执行)。排队是”等位”,不是”干活”,设计上是分开的。但整体会受 MAIN_AGENT_TIMEOUT_SEC=300s 的限制,如果排队太久主任务超时会一起取消。

追问 3:parallel_dispatch_tool 的平台覆盖保障是怎么做的?

_ensure_platform_coverage(demands_list) 函数。检测 demands 列表里提到了哪些平台名(大小写不敏感子串匹配),如果发现缺了某个平台,就克隆一条 demand 模板,前缀加上 "只在 {platform} 这一个平台检索……"。保证 5 个平台全覆盖,即使模型只写了 3 条 demand。


Q4:你提到”机制兜底不靠 prompt”,能展开讲讲这个设计哲学吗?#

回答:

核心观点是:LLM 是概率模型,prompt 里的规则它可能不遵守,但代码级的限制它绕不过。整个项目里有四个典型案例:

案例 1:子 Agent 不能调终结工具

prompt 里可以写”子 Agent 不要调 shopping_summary”,但弱模型可能忽略。代码里的做法是在 harness 的 tool_gates.py 里检查 current_fork_depth() >= 1,如果子 Agent 调了 shopping_summary直接返回一条 sentinel ToolMessage 告诉它”这个工具在子 Agent 不可用”,根本不执行:

if tool_name in DEPTH0_ONLY_TOOLS and depth >= 1:
    return _SUB_AGGREGATION_DENIED  # 固定拒绝文本
python

案例 2:检索预算耗尽后在执行层拦截

prompt 写”搜索够了就别搜了”没用。代码做法是在 harness 的 tool_gates hookpost_tool_call 阶段)检查预算,搜满就回一条 sentinel 消息、不执行 item_search/web_search(工具照常在模型的工具表里)。⚠️ 早期版本是”从 request.tools 里移除让模型看不到”(_tools_to_drop),后来为保 prompt cache 前缀撤掉了——移除工具会打断前缀缓存(见 M6.1 / Mperf.1)。代价是模型仍会为被拒的调用花一次解码,但换回缓存前缀稳定。

案例 3:web_search 的使用场景限制

web_search_allowed() 的逻辑是:只有在”还没搜过商品”或者”搜了但全是空结果”时才允许调 web_search。如果已经有了商品候选,web_search 就被关闭。这防止模型在已经有结果的情况下还去外部搜索浪费时间。

案例 4:item_picker 出口注入

item_picker 工具返回结果时,harness 的 result_guard hook 会在结果末尾追加一条 SUMMARY_NUDGE(“下一步请调 shopping_summary 收尾”)。这利用了 LLM 对最近上下文注意力更高的特性——比 system prompt 开头的规则更有效

案例 5:不问模型”这算哪一档”,只问它”用户在哪儿说的”

planner 要把「不要塑料的,尽量别太花哨」拆成硬淘汰(命中即删)和软避讳(只减分)两档。实测同一句话跑 8 次,模型有 1/4 的概率把「花哨」归进硬淘汰——一旦归错,用户被删掉一大片商品还看不见原因。提示词里已经明写了「弱表达放 soft_dislikes,别混」,它照样混。

解法不是把这条规则再写重一遍,而是换一个问法:硬排除词的 schema 从 list[str] 改成 list[ExcludeTerm],每个词必须附上 evidence(用户原话片段)。档位不再由模型判,而是由代码扫 evidence 里的弱表达标记(「尽量」「别太」「不太」…)确定性地判——命中就降级成软避讳。

关键在于:模型判断档位不稳,但转述原话是稳的。 把一个它做不好的任务(判断),换成一个它做得好的任务(复述),判断本身交给代码。附带好处是中英文自动一起兜住:flashy 这个英文词单看无法判断强弱,但它的 evidence 同样是那句中文原话,所以跟着一起降级——纯字面比对在这里是失效的。

上线后真实复测 8/8 干净,而且模型根本不再往硬排除桶里填「花哨」——降级闸压根没被触发。要求它为每个硬淘汰说出依据,这个要求本身就成了 forcing function:说不出用户在哪儿说过,它自己就不敢填了。

追问:有没有某些场景是必须靠 prompt 的,不能用机制?

有。创造性决策必须靠 prompt——比如”什么时候该 fork”、“怎么组织搜索策略”、“怎么判断信息够了”。这些是模型的核心决策能力,代码不应该代替。我的原则是:边界条件靠机制,决策空间靠 prompt

但要注意「必须靠 prompt」和「只能求模型别犯错」是两回事。案例 5 里判断档位看起来像个决策问题,可它的失败是可观测的(模型自己都拿不准,会把同一个词往两个桶里都填),只要能观测就能兜。真正只能靠 prompt 的,是那些连”错了”都定义不出来的地方。


二、工具体系#

Q5:你的工具里,哪些用了 LLM,哪些是纯规则?为什么这么分?#

回答:

FULL_TOOL_SET 共 14 个工具(12 业务 + 2 元工具)。分类原则:能确定性的绝不用 LLM

工具是否用 LLM核心逻辑
planner是(structured output)意图拆解为结构化字段 + 意图路由
item_search向量检索 + payload 过滤
price_compare汇率归一 + 排序
shipping_calc查表计算关税运费
category_insightRAG 检索 + cross-encoder rerank
item_picker多维规则打分 + 硬过滤 + 品类 rerank
web_searchTavily API 调用
shopping_summary是(fast LLM,structured output)生成推荐理由 + 提取新偏好
chat_fallback非购物闲聊
ask_userFuture 桥接,阻塞等待用户回复
forget_preference从偏好 Store 里删除指定条目
image_understand是(VL 模型)参考图理解(用户上传图片时)
dispatch_tool否(元工具)串行 fork 入口
parallel_dispatch_tool否(元工具)并行 fork 入口

item_picker 为例,精选逻辑完全是多维规则打分(六路加分 + 两路减分):

score = W_MATCH_HARD(2.0) × 硬需求关键词命中数
      + W_MATCH_HARD_SEM(1.0) × 硬需求语义相似度
      + W_PREF(1.0) × 软偏好关键词匹配数
      + W_MATCH_SEM(0.7) × 软偏好语义相似度
      + W_AFFINITY(0.2) × 行为亲和命中数
      + W_RATING(0.6) × rating/5.0
      + W_CHEAP(0.4) × (max_price - price) / (max_price - min_price)
      − W_ATTENUATOR × 软避讳关键词命中数
      − W_ATTENUATOR_SEM × 软避讳语义相似度
      − W_RERANK_MISS(2.0) × (品类 rerank 不通过时)
plaintext

用规则而不用 LLM 有三个好处:

  1. 可复现——同输入同输出,方便回归测试
  2. 可解释——用户问”为什么推荐这个”,能拆开分数看各维度贡献
  3. 成本——候选可能 50-100 个,逐一让 LLM 评估太贵(一次精选可能消耗上万 token)

只有三个场景用 LLM:理解自然语言(planner 解析意图)、生成自然语言(shopping_summary 写推荐理由、chat_fallback 闲聊)和多模态理解(image_understand 读参考图)。这些是 LLM 不可替代的能力。

追问 1:planner 的货币解析为什么不完全交给 LLM?

因为 LLM 的货币识别每次可能不一样。用户说”预算 300”,模型有时猜 USD 有时猜 CNY。所以 planner 分两步:

  1. LLM 做 structured output 拆解意图(品类、偏好等)
  2. 确定性的正则规则 resolve_budget_currency(intent) 匹配货币符号/代码($ → USD,¥/元/块 → CNY),覆盖掉 LLM 的猜测

如果正则匹配不到明确货币,用 DEFAULT_BUDGET_CURRENCY=CNY(因为大部分用户是中文输入)。然后通过静态汇率表转成 USD。这样每次结果一致。

追问 2:shopping_summary 为什么用 fast LLM 而不是主 LLM?

实测发现端到端 295 秒里 96% 是 LLM 解码时间。shopping_summary 的任务相对简单(从已有 picks 里写理由),不需要深度推理。用 get_fast_llm()(关闭 reasoning/thinking)能大幅降低延迟。代码里 get_fast_llm() 通过 extra_body={"enable_thinking": false} 关闭推理模式。

追问 3:item_picker 的”不喜欢”过滤为什么是硬删除而不是减分?

实测经验。减分在候选多的时候,不想要的东西分数被其他维度拉上来还是会入选。比如用户说”不要塑料的”,一个塑料的旅行箱评分 4.9、价格最低,减分后总分可能还在前 5。硬过滤直接剔除,确保用户的否定偏好被绝对尊重。代码里 exclude_keywords 和从 dislike_exclude_terms(user_id) 获取的长期”不喜欢”关键词合并后,候选的 (title+brand+category).lower() 包含任一关键词就被移入 excluded 列表。


Q6:商品数据在整个链路里是怎么流转的?#

回答:

核心是 ItemCandidate 这个 Pydantic 模型,在链路中渐进式填充字段:

阶段 1 — item_search 产出:
  item_id, platform, title, brand, price, currency, rating, score

阶段 2 — price_compare 补充:
  + price_usd(汇率归一后的美元价)

阶段 3 — shipping_calc 补充:
  + shipping_usd, duty_usd, landed_usd(到手价 = 商品价 + 运费 + 关税)

阶段 4 — item_picker 补充:
  + pick_reason(入选理由文本)

阶段 5 — shopping_summary 回填:
  + image_url, url(从候选缓存回填,不经过 LLM)
plaintext

有两个关键设计决策:

决策 1:URL/image_url 不传给 LLM

工具调用时用 compact_candidates() 把 url 和 image_url 字段剥离,只传业务字段给模型。原因是 LLM 经常幻觉出不存在的 URL——它会编造一个看起来合理但实际不存在的链接。所以从源头杜绝:URL 永远从数据库(候选缓存 _CANDIDATES 字典)回填,不经过模型。

决策 2:候选注册机制

item_search 执行完后,会把候选写入 _candidates.py_REGISTRY 字典——session_dir 为 key 的 module-level dictsession_dir → {item_id → ItemCandidate}),主 Agent 和所有子 Agent 的 session_dir 相同(子继承父),自然共享同一份候选池。后续 shopping_summary 在 LLM 输出结构化结果后,用 enrich(item_id)_REGISTRY 回填完整信息(URL、图片等)。如果模型输出了一个不存在的 item_id(幻觉),就留空——前端用占位符显示。

run_agent() 收尾时先调 persist_candidates() 把候选池落到 session_dir/candidates.json(支持多 worker 续聊),再调 reset_candidates() 清内存池——防模块级 dict 无界增长。

追问 1:为什么不在 item_search 阶段就把 price_usd 算好?

因为职责分离。item_search 是召回工具,职责是”找到相关商品”;price_compare 是比价工具,职责是”统一货币比较”。虽然技术上可以在 item_search 里顺手做汇率转换,但这样的话 item_search 就要依赖汇率表,而且如果用户后来改了 base 货币,就得重新搜索。分开做更灵活,也更容易单独测试。

不过 Qdrant 的 payload 里其实已经有 price_usd 字段(入库时预计算的),item_search 的 price_usd_max 过滤直接用的就是这个字段。所以严格说 item_search 返回的 RecallCandidate 里已经有 price_usd 了,但 price_compare 还是会重新用实时汇率计算一遍(可能有汇率更新)。

追问 2:候选缓存是 module-level dict,多个会话会冲突吗?

不会。候选池以 session_dir 为 namespace key(_REGISTRY: dict[str, dict[str, ItemCandidate]]),不同会话的 session_dir 不同,天然隔离。同一会话内的主/子 Agent 共享同一 session_dir,看到的是同一份候选池——这正是期望行为(子 Agent 搜到的候选要给主 Agent 的 item_picker 用)。为什么不用 ContextVar?和检索预算一样的原因——ContextVar 的 set 不回传给父,子 Agent 注册的候选父看不到。


Q7:category_insight 的两级缓存是怎么设计的?#

回答:

category_insight 是对 RAG 知识库的检索(品类爆款、属性分布、价格带等),结果是弱时效性的(品类知识不会分钟级变化),所以适合缓存。实现了两级:

第 1 级:精确缓存(O(1) 查找)

  • Key = f"{depth}:{category}",比如 "quick:luggage"
  • 完全匹配命中直接返回

第 2 级:语义缓存(向量相似度)

  • 对 category 文本做 embedding,用 SemanticCache 查找相似度 ≥ 0.92 的已缓存结果
  • 比如 "旅行箱""行李箱" 语义相近,精确 key 不同但语义缓存能命中
  • depth 分组(quick 和 deep 不混用)

配置:max_entries=256ttl=3600s(1 小时过期)。

为什么只给 category_insight 做缓存,不给 item_search 做?

因为 item_search 的结果是强时效性的——商品价格、库存、评分随时在变,缓存会导致用户看到过时信息。而品类知识(“旅行箱品类的主流品牌是什么”)短时间内不会变,缓存安全。

追问:语义缓存的 0.92 阈值怎么定的?

经验值。太低(如 0.8)会出现误命中——“旅行箱”和”收纳箱”语义接近但品类知识完全不同。太高(如 0.98)基本等于精确匹配,失去语义缓存的意义。0.92 在实测中既能命中同义词,又不会跨品类误匹配。

最优方案补充: 当前语义缓存是内存级的(进程内 dict),重启就丢。生产环境可以用 Redis + 向量索引(如 Redis Search 的 VECTOR 字段)做持久化语义缓存,支持多实例共享。但当前单实例部署场景下内存缓存够用。


三、向量召回#

Q8:你的向量检索架构是怎样的?#

回答:

架构源自 refdocs 的「三塔 + 双通道」设计,但落地后已收敛为单编码器

  • 不自训模型(无 GPU),query 与商品文本共享同一个预训练 embedding(BGE-M3,1024 维),走 OpenAI 兼容 /embeddings 在线推理。
  • 个性化通路(user 塔 + fuse 加权融合)已删:实践中该通路长期零调用。个性化改走「偏好词并入检索词」(planner 把”喜欢小众”等偏好词塞进 query)+ Qdrant payload filter(平台/价格/评分多维过滤),向量级融合通路按减法原则移除。

检索流程:

1. encode_query(query + prefer_keywords) → query_vec (1024d)
   # 偏好词直接拼进 query 文本一起编码,不再单独编码 user_vec
2. Qdrant.search(query_vec, payload_filter, top_k=20) → candidates
3. RELEVANCE_FLOOR=0.45 → 低于此阈值的结果直接丢弃
plaintext

为什么删掉了 user 塔向量融合?

两个原因:① 偏好描述往往很短(“喜欢小众品牌”就 5 个字),embedding 噪声大,融合后反而拉入不相关结果;② 偏好词并入检索词 + payload filter 的组合更直接、更可控——“不要塑料” 走 item_picker 硬过滤(向量空间无法表达否定语义),“喜欢小众” 编进 query 文本让 embedding 自然捕捉。效果更好且维护成本低。

Qdrant payload filter 具体怎么过滤?

must = []
if platform != "all":
    must.append(FieldCondition(key="platform", match=MatchValue(value=platform)))
if price_usd_max:
    must.append(FieldCondition(key="price_usd", range=Range(lte=price_usd_max)))
if min_rating:
    must.append(FieldCondition(key="rating", range=Range(gte=min_rating)))
python

在 ANN 搜索同时应用过滤——Qdrant 的实现是过滤后做近似最近邻,比”先搜后过滤”精准(不会因为过滤丢失 top_k 数量)。

追问 1:为什么”不喜欢”不编进检索 query,而走硬过滤?

向量空间里的否定语义很难表达。“不要塑料” 的 embedding 和 “塑料” 很接近(它们在语义空间里是相关的,只是极性不同),如果把”不要塑料”编码进 query,反而会把含”塑料”的商品拉近。所以”不喜欢”走硬过滤——item_picker 里直接排除包含关键词的候选,或者通过 Attenuator 减分(软避讳)。向量做”要什么”,过滤做”不要什么”,各用最合适的工具。

追问 2:embedding 服务挂了怎么办?

towers.py 有三层降级:

  1. 重试 + 指数退避(3 次,1s→2s→4s)——扛临时抖动
  2. 熔断器CircuitBreaker)——连续 5 次失败后快速失败,不再等 timeout
  3. 本地 n-gram fallback——字符 3-gram 哈希到固定维度(256d)的确定性向量。质量差很多但保证接口不断。用 MD5 哈希(不是 Python 内置 hash,因为 Python hash 每次启动随机种子不同,不稳定)

实际上线后确实遇到过 embedding 服务超时,降级到本地 fallback 后用户还是能搜到东西,只是排序没那么精准。

追问 3:为什么选 Qdrant 而不是 Faiss?

最初用的 Faiss,后来换了 Qdrant。核心原因是 payload filter

  • Faiss 只能做纯向量检索,过滤得在检索后外部做——先搜 100 个再过滤到 20 个,但如果前 100 个里合格的不到 20 个就会”缺货”
  • Qdrant 支持在 ANN 搜索时同时应用多维 payload filter(平台、价格范围、评分范围),一次查询出来的就是过滤后的 top_k

Qdrant 还支持 on-disk 模式(不吃全部内存)、gRPC、持久化,更接近生产级。


Q9:数据入库时踩过什么坑?#

回答:

两个严重的数据质量问题:

坑 1:Amazon 价格科学计数法

Amazon 数据集里约 14% 的价格是科学计数法格式(如 2.29e+01),naive 的字符串处理会把它当成 2.29 而不是 22.9价格差 10 倍。解决方案是在 clean.pynormalize_row() 里统一用 float() 解析(Python 的 float 原生支持科学计数法),并加了回归测试锁定这个 case。

坑 2:upsert 单请求超 32MB

往 Qdrant 写入 6 万条记录时,如果一次性 upsert 会超过 HTTP 请求体 32MB 限制。解决方案是 UPSERT_BATCH=512,每 512 条一批分批写入。

for i in range(0, len(records), UPSERT_BATCH):
    batch = records[i:i+UPSERT_BATCH]
    client.upsert(collection, points=batch)
python

还有一个隐性问题:Shopee 的货币覆盖不全——62% 的商品缺少 COP/MXN/CLP 的汇率支持,导致 price_usd 算不出来,搜索时 price_usd_max 过滤会把它们全部漏掉。发现后补了汇率表才解决。

追问:你怎么做数据去重的?

(normalized_title, price_rounded_to_2_decimals) 为 key 去重。不用 item_id 是因为有些平台同一个商品有多个 SKU(不同颜色/尺寸),item_id 不同但实质是同一个商品。碰撞时保留字段更完整的那条(_completeness() 计数非空可选字段数)。


四、上下文压缩#

Q10:50 轮以上的多轮对话,上下文怎么管理?#

回答:

核心方案叫 Cache Breakpoint,关键洞察是——压缩和缓存是耦合的,不能分开做

把对话历史分成两个区域:

[消息0 ... 消息N-K]  ←  冻结区(breakpoint 之前)
[消息N-K+1 ... 消息N] ←  工作区(最近 K 轮)
plaintext
  • 冻结区:工具结果被截断到 DEFAULT_MAX_TOOL_TOKENS=1500 token,然后打上 cache_control: {"type": "ephemeral"} 标记。这段内容不再变化,下次请求时能命中 prompt cache(缓存价远低于输入价)
  • 工作区:保持完整,模型需要精确的上下文来做当前决策。默认 keep_recent=3(最近 3 个工具调用轮次)

compute_breakpoint() 的具体逻辑:找到所有 ToolMessage 的索引位置,如果总数 ≤ keep_recent 就不压缩(全部保留);否则返回”第 N-K 个 ToolMessage 的索引”作为分界点。

为什么不每轮都全量压缩?

因为全量压缩会破坏缓存前缀。Anthropic/DeepSeek 的 prompt cache 要求前缀精确匹配——如果每轮重新压缩整个历史,前缀每轮都在变,cache 永远命不中。Cache Breakpoint 的设计保证冻结区一旦压缩就不再改变,前缀稳定,cache hit rate 上升。

压缩作为 harness hook(context_compress.py)挂在中间件栈里,每次模型调用前执行awrap_model_call 阶段调 post_step_compress)。

追问 1:CJK 文本的 token 计数有什么坑?

最初用的是”字符数 ÷ 4”估算,对英文基本准确(1 token ≈ 4 字符),但中文完全不对——中文 1 个字大约 0.75-1.5 个 token,用 ÷4 会低估 3 倍。后果是压缩算法以为还有很多空间,实际已经超了上下文窗口。

解决方案:tokens.py 里引入 Qwen tokenizer(本地,不需要 API key)做精确计数,加 CJK 启发式 fallback(对每个字符判断是否 CJK,CJK 按 0.75 token/字,其他按 0.25 token/字)。tokenizer 首次加载 3.3 秒,在 server lifespan startup 时预热(warm_tokenizer())。

为什么用 Qwen tokenizer 而不是 DeepSeek 原生的?两者都是 BPE,token 密度接近,但 Qwen 的 Python 包(dashscope)更稳定。

追问 2:cache_control 的限制有哪些?

Anthropic 的限制是单请求最多 4 个 cache_control 标记,且有最小写入阈值(约 1024 token 前缀)。代码里只在 breakpoint 位置打一个标记,不会超过 4 个限制。如果前缀太短(对话刚开始),apply_cache_control() 会跳过不打标记。

最优方案补充: 当前是五层压缩体系:L0 工具侧限速(item_search 最多返回 20 条,剥离 URL)→ L1 动态配置 → L2 Cache Breakpoint → L3 会话摘要(设计了但未启用)→ L4 长期记忆。更激进的方案是实现 L3——对冻结区做一次 LLM 摘要,进一步缩短前缀。但这需要额外一次 LLM 调用,且摘要本身可能丢失关键信息,所以当前评估后没启用(L2 已经够用)。


五、长期记忆#

Q11:用户偏好是怎么存储和读取的?跨会话怎么持久化?#

回答:

存储模型:

每条偏好是一个 PreferenceEntry

授权分档(blocking 的设计决策):硬淘汰权只由用户显式授予source="user"blocking=True),agent/curator 学到的偏好一律只减分。原来的 strength: hard|soft 是 curator 的 LLM 出来的,而 hard 意味着永久、跨品类、静默的硬淘汰——让一个每轮都在猜的模型决定「这件商品用户永远不该看到」,风险和收益完全不匹配。硬软的分界不该是 LLM 的置信度,而该是信息的来源。

去重机制: dedup_key 由代码从 polarity/category/domain/slug 四个结构化字段确定性拼出来,不是 LLM 手拼的字符串——这四个字段本来就各自独立存在,让 LLM 再拼一遍复合字符串等于同一份身份信息存两份,还可能自相矛盾(真实踩过这个坑:早期两条写入路径对这个拼接字段的命名不一致,导致其中一条路径生成的值被静默丢弃退化成内容哈希,去重直接失效)。同一个偏好反复表达命中相同 dedup_key 时走 _merge_on_collision():内容/keywords 取最新、保留旧的 created_at、刷新 last_confirmed_at(供半衰期衰减,不是简单的置信度累加)。

读写时机:

  • :会话结束、回复已下发之后,由独立的记忆管家 curator 统一扫本轮对话判定”跨会话一贯取向”并提升为长期偏好,是长期库唯一的写入口。注意它不产会话约束——短期状态 P_t 里的约束由 planner 当轮写入,分工是”产生归 planner,撤销归 curator”(详见下面的追问 2)
  • :下次会话的 run_agent() 入口分层注入——dislike-hard(安全/绝对排除类)永远全量;其余条目数超阈值(默认 20)后才按当前 query 做语义 top-k 裁剪,否则也全量(量小时全量最简单可靠)。注入位是当轮 human message<user_long_term_preferences> 块、不进 system prompt——偏好每轮随 query 裁剪、每轮都变,混进 system prompt 会打断跨轮 prompt cache 前缀(跨轮缓存治理见 docs/milestones/M6.1);这只是「给模型看」的文本,偏好对召回 / 精挑的硬执行另走 ContextVar,与注入位置无关

唯一后端——SQLAlchemy/DB:

不再有后端抽象基类:原来 ABC + LocalFileStore + RedisStore 的三层结构,是为了「离线可跑」设计的。现在本地用 SQLite(零配置),生产用 PostgreSQL(DATABASE_URL 环境变量切换),统一走 SQLAlchemy async session。偏好条目与 ORM 行 app.db.models.Preference 互转(PreferenceEntry.from_row()),去重靠数据库唯一约束 (user_id, dedup_key),碰撞时走 _apply_merge() 覆盖合并。

偏好在工具链中的应用:

  • like 偏好 → 偏好关键词并入 item_search 的检索 query(拼进 planner 输出的 prefer_keywords),不再做向量级融合
  • dislike + blocking=True(用户授权的)→ 注入 item_pickerexclude_keywords 列表(硬过滤,命中即删)
  • dislike + blocking=False(agent 学到的/软避讳)→ 注入 item_pickerdeprioritize_keywords 列表(Attenuator 减分,不淘汰)
  • 两条路径完全分开——向量空间无法表达否定语义,所以否定走关键词匹配

追问 1:语义裁剪(read_relevant)为什么删了?

原来 read_relevant 复用 TowerClient 对偏好做 cosine similarity top_k 裁剪。已删:时间衰减(recency_weight)整个删除后,连带删掉了 read_relevant / rank_relevant。store 因此不再依赖召回层。当前策略更简单——dislike-hard(安全/绝对排除类)永远全量注入;其余条目数超阈值(默认 20)后才做裁剪,否则也全量。量小时全量最简单可靠。

追问 2:为什么从 LocalFileStore/RedisStore 双后端改成统一 DB?

LocalFileStore 多实例各写各的文件、数据不一致;RedisStore 没有事务保证、和 Preference 表的 ORM 不通。M16 上线账户体系(users/threads 表)后,偏好也统一进了同一个数据库——所有用户数据只有一个持久化来源,Alembic 管迁移、ORM 管一致性,比维护三套后端 + 抽象基类简单得多。

追问 3:会话约束为什么由 planner 产、而不是 curator?

一开始就是 curator 产的,踩了个真实的坑才改过来。会话约束分两档:「不要塑料」是硬淘汰(blocking=True,命中即从结果里删),「尽量别太花哨」是软避讳(blocking=False,只减分)。planner 每轮都跑、看的是用户原话,能正确区分这两档;而 curator 跑在收尾之后,它的输出 schema 里没有 blocking 字段,于是每轮都会用一个信息更少的版本去覆盖 planner 的正确结果——「尽量别太花哨」被写成硬淘汰,下一轮续聊直接把所有「花哨」的商品杀光,用户看不到任何被删的痕迹。

所以分工收敛成一句话:约束的产生归 planner,撤销归 curator。撤销(用户改口「算了塑料也行」)只有 curator 做得了——planner 只看得见本轮说了什么,看不见「这句话推翻了上一轮的哪条约束」。

更值得讲的是这个 bug 复发了一次。堵掉 curator 的覆盖路径之后,跑端到端才发现第二轮又变成硬淘汰了——这次的肇事者是 SessionPrefState.render():它把 P_t 渲染成文字拼进下一轮的 prompt,而标签只按 polarity 分「排除 / 需要」两档,软避讳被渲染成「本轮约束(排除):尽量避免花哨」。下一轮的 planner 照着「排除」二字,就把「花哨」原样填回了硬排除桶。同一个 bug 的两种长法:blocking 这一维在哪个出口丢失,它就在哪里复活。 教训是——一个维度只要有多条出路(覆盖写入、渲染成文本再被读回),就得每条都堵,而不是堵掉最先发现的那条就以为完事。


Q12:偏好全是 LLM 从对话里”猜”的,有没有更硬的证据?(行为亲和)#

回答:

结论先行:有一路,而且是全系统唯一一路不经过 LLM 的偏好证据——收藏

在此之前,长期库里每一条偏好都是 curator 拿 LLM 从对话文本里抽的,共同盲区有两个:① 人对自己口味的自陈 既不全也不准(没人会在买 T 恤时主动说”我偏好纯棉”,那是默认到说不出口的偏好);② 凡 LLM 抽的就有幻觉。 而 favorites 表里躺着一批最硬的信号,一直只用来渲染收藏抽屉——点收藏是要付出成本的真实动作, 推荐系统几十年的共识就是隐式行为 > 自陈偏好。

关键细节:纯统计,零 LLM、零延迟、无幻觉。从收藏商品的标题里数属性词(材质/功能),同一属性被 ≥2 件 收藏命中才算信号(收藏一件可能只是随手存链接),聚合成 item_picker 的弱加分项。

档位压到最低,因为它是我们推断的、用户从没说过:只加分绝不淘汰不进检索词(检索词决定捞哪一池, 代价是全局的;打分只在池内微调,代价是局部的)、冲突时让位于显式表达。全系统统一的一把尺子: 用户明说的 > 用户做过的 > 模型推断的——这条尺子同时决定了能不能淘汰商品、能不能进检索词、冲突时谁让位、 理由里怎么措辞(卡片上写”和你此前收藏的是同一路(麻)“,而不是”正是你要的『麻』“——用户压根没说过这个词, 把推断说成他的要求就是编造事实)。

生产经验(效果 + 边界都要讲):固定候选池 A/B,B 组收藏 2 件亚麻单品后,池内 7 件亚麻款从 #3/#8/#9/#16/#18/#20/落榜 全部上浮进前七,清单件数不减(证明没淘汰任何东西)。但主动讲边界:属性词表对 真实商品库覆盖率 29.8%,且按品类严重分化——服装类有效,包袋类几乎是 0(旅行收纳包那类 query 上基本 空转);权重 0.2 是调出来的,没做消融标定

追问 1:这么数词,怎么保证数出来的是对的?——踩过反向的坑

一个素食用户收藏了 "Vegan Leather-Free Tote Bag""Leather-Free Canvas Backpack",他明摆着在躲开 皮革。我第一版抽词用了裸子串 if token in title——而 "leather" 这个字符串确确实实出现在 "vegan leather-free" 里面。两件收藏各投一票,leather 跨过证据阈,系统学到的结论是「这人偏好皮革」, 接着给所有真皮款加分。他越是精心挑无皮革的东西,系统越把真皮顶到他眼前——学出来的偏好完全是反的。

为什么会写成这样,才是重点:仓库里早就有 term_hits()(词边界 + 否定修饰判定),它上面的注释白纸 黑字记着同一次真实误杀——“裸子串把 vegan leather / leather-free 全杀了,而这些正是不喜欢皮革的人想要的”。 正确的抽象就在同一个文件里,我写新函数时没复用它。 这类 bug 最常见的成因不是想不到,而是已有的正确 抽象没被复用

追问 2:用户明说”不要皮革”,但收藏夹里有皮包,听谁的?

设计上早定了:说过的话压过做过的事(不许一边减分一边加分,否则净效果取决于两个权重谁大,不可解释)。 但这条纪律的实现在最常见的真实形态下静默失效了

用户说「找个双肩包,不要皮革的」,curator 落库的是 keywords=["皮革"]——中文,这是它从中文对话抽词的 真实形态。而收藏夹里是英文标题 "Genuine Leather Bag"。第一版压制写成 if t not in blocked(精确相等), 两道坎一道都没跨过去:① 跨语言——中文”皮革” vs 英文 genuine leather,八竿子打不着(而 item_pickermem.penalty 去匹标题前是先跑 normalize_terms 归一的,我在压制这一路漏了同一道工序); ② 长词变体——就算归一了,blocked 里是 leather、亲和 token 是更长的 genuine leather,精确相等照样 穿过去。实测修复前:penalty=['皮革'](减分正常)、affinity=['genuine leather'](加分照常放行)—— 一边给皮革减分、一边给皮革加分。用户明明白白说了不要,系统反而把真皮往上顶。

修法:压制判定必须和匹配判定用同一套口径——先归一,再用 term_hits 判”这条排斥词命中了这个亲和词吗”。

追问 3:这么明显的 bug,测试没拦住?——没有,而且更糟

test_explicit_dislike_beats_favorites 就是专门守这条纪律的,而且一直是绿的。因为我写测试时图省事, keywords 填了英文 ["genuine leather"]——恰好与亲和 token 完全同形,精确相等成立,压制”生效”,测试通过。 它只守住了「英文 / 单词 / 完全同形」这条最窄的路——而那恰恰是真实链路里唯一不会发生的情况(curator 从中文 对话抽词,存的必然是中文)。改成真实形态 ["皮革"],立刻变红。

教训:测试要照着数据的真实形态写(中文原子词、否定修饰的标题、长词变体),别照着实现里最好写的那条路径写—— 后者只是在给实现拍照,不是在验证它。

追问 4:你怎么验证它真的生效?——第一次验错了

直觉做法是端到端 A/B:两个用户、同一条 query,一个有收藏一个没有。烧了 4 次真实 LLM 任务,什么也没验到: ① planner 每轮产的检索词有随机性 → 两个用户的候选池根本不同 → 排序差异无法归因;② 更阴的是,我用「夏天 穿的男士短袖」当 query,planner 自己从”夏天短袖”脑补出”棉” 塞进了 prefer_keywords → cotton 走了显式偏好 那档 → 亲和按去重规则正确地让位 → 观察窗口被自己的正确逻辑吃掉了

改用固定候选池(同一批 25 件真实商品、同一个真实 picker,唯一变量是收藏)+ 换成 亚麻(planner 绝不会 脑补出亚麻),一次就拿到干净的因果证据。教训:想验证一个确定性组件,别把它塞在非确定性链路(LLM)的下游去 观察——端到端只能证明”链路不崩”,证明不了”这个组件按预期工作”。


六、实时通信与 API#

Q12:前后端实时通信的 connect-first 协议是什么?解决了什么问题?#

回答:

协议流程:

前端生成 thread_id
  → 连接 WS /ws/{thread_id}
  → 等待收到 ws_ready 控制帧
  → 发送 POST /api/task {thread_id, query}
  → Agent 在后台 asyncio.create_task 执行
  → 事件通过 WS 实时推送
plaintext

解决的核心问题:事件丢失竞态

如果先 POST 再连 WS:

  1. POST /api/task → 后台开始执行 → 产生 session_created 事件
  2. 前端还在连 WS…
  3. WS 连上了,但 session_created 已经发过了,前端没收到

connect-first 保证 WS 通道在任务启动前就就绪,零事件丢失,不需要事件缓冲区

代码在 server.pyws_endpoint 里:先 await websocket.accept(),注册到 ConnectionManager,发送 {"type": "ws_ready", "thread_id": thread_id} 控制帧,然后进入接收循环。前端收到 ws_ready 才发 POST。

追问 1:为什么选 WebSocket 而不是 SSE?

三个原因:

  1. 双向通信——用户回复澄清问题(ask_user 工具)时需要从前端往后端发消息,SSE 是单向的(server→client)
  2. 会话级长连接——Agent 执行可能几分钟,SSE 是请求级的,超时处理麻烦
  3. 事件类型多样——8 种事件(session_created、tool_start/end、fork、clarification 等),不只是文本流

SSE 适合 ChatGPT 那种”一问一答”流式输出,ShoppingX 是”一问多步”长任务 + 多类型事件 + 双向交互。

追问 2:如果用户刷新页面,WS 断了怎么办?

event_log.py 做了 Redis Stream 持久化。每个事件在推 WS 的同时 XADD 到 Redis Stream(key = thread_id)。重连时前端带上 last_event_id,后端调 replay_after(thread_id, last_event_id) 用 XRANGE 查出后续事件补发。

Stream 有两个保护:MAXLEN=200(每个 stream 最多 200 条,近似截断)、TTL=6h(自动过期清理)。

追问 3:ConnectionManager 里如何处理”新旧 WebSocket 交替”的竞态?

对象身份(is)检查disconnect() 方法里:

if self._connections.get(thread_id) is websocket:
    del self._connections[thread_id]
python

如果用户快速刷新,新 WS 注册覆盖了旧 WS 的 key,旧 WS 的 disconnect handler 稍后运行时,is 检查发现当前注册的不是自己(是新 WS),就不删除。避免了”旧 WS 的 finally 块误删新 WS”的竞态。


Q13:后端的任务管理是怎么做的?如何处理取消?#

回答:

任务生命周期:

POST /api/task 不等执行完成,立即返回 {thread_id}。实际执行在 asyncio.create_task(_runner(...)) 里异步进行。_runner 封装了 run_agent() 调用 + slot 释放。

任务槽位管理(背压控制):

  • PriorityRequestQueueapp/api/concurrency.py)限制同时运行的任务数
  • 新任务调 try_reserve(kind):有空位就占、返回 Reservation;没有返回 None,API 返 429 + Retry-After
  • 同 thread_id 重新提交调 force_reserve(kind):取消旧任务 + 占旧任务的位
  • 有位但当前繁忙时排队等待await wait_turn(res)),不是立即执行——endpoint 同步段只做「占位判定」,真正等槽在后台协程里

取消机制: POST /api/task/{thread_id}/canceltask.cancel(),向 asyncio Task 注入 CancelledErrorrun_agent() 的异常处理:

except asyncio.CancelledError:
    await monitor.report_task_cancelled()
    raise  # 让 _runner 的 finally 释放 slot
python

取消不是瞬时的——需要等到下一个 await 点才能生效。LangGraph 的 ainvoke 内部有大量 await 点,所以通常几百毫秒内就能响应取消。

追问 1:force_reservetry_reserve 的区别是什么?

try_reserve 是”没位就拒绝”(返回 None,API 返 429 + Retry-After);force_reserve 是”没位也要进”——先取消同 thread_id 的旧任务释放一个位,然后占上。用于用户在同一会话重新提问(前端用同一个 thread_id),旧任务应该被替换而不是排队。

追问 2:任务的 finally 块里如何避免误删新任务?

asyncio.current_task() 身份检查。旧任务被取消后,_runner 的 finally 块执行清理,此时新任务可能已经占了同一个 thread_id 的 slot。清理逻辑检查当前任务 handle 是不是自己——如果 slot 里存的已经不是自己(是新任务),就不释放 slot。同时清理逻辑用 Reservation 的 release() 释放槽位,保证计数准确。


七、工程基础设施#

Q14:熔断器是怎么设计的?为什么用连续失败计数而不是失败率?#

回答:

三态状态机:CLOSED(正常)→ OPEN(快速失败)→ HALF_OPEN(探测恢复)

class CircuitBreaker:
    failure_threshold: int = 5    # 连续失败 5 次 → OPEN
    recovery_timeout: float = 30  # 30 秒后 → HALF_OPEN
python

转换逻辑:

  • CLOSED 状态下每次失败 _fail_count += 1,到达阈值 → OPEN + 记录时间
  • OPEN 状态下调用直接抛 CircuitOpenError(快速失败,不等 timeout)
  • 超过 recovery_timeout 后自动变 HALF_OPEN,放过一次探测请求
  • HALF_OPEN 探测成功 → CLOSED(清零计数);失败 → 回到 OPEN

为什么用连续失败计数而不是失败率(如 Hystrix 的滑动窗口百分比)?

因为这个项目的外部服务调用频率很低(embedding、reranker、web_search 每个会话就几次)。低频场景下用失败率会严重误判——10 次调用失败 1 次就是 10% 失败率,看起来很高但其实只是一次偶发抖动。连续失败计数更适合低 QPS 场景:5 次连续失败才说明服务真的挂了,任何一次成功就清零。

项目里三个地方用了熔断器:

  • web_searchCircuitBreaker("web_search", threshold=5, recovery=30)
  • rerankerCircuitBreaker("reranker", threshold=5, recovery=30)
  • event_log(Redis Stream):CircuitBreaker("event_log", threshold=3, recovery=30)

所有 breaker 注册在全局 _BREAKERS dict 里,all_breakers() 可以枚举所有实例状态,用于监控指标暴露。

追问 1:熔断器和重试是怎么配合的?

重试在熔断器内部breaker.call(lambda: call_with_retry(fn, attempts=3))。逻辑是:先走 3 次重试(指数退避 + jitter),如果 3 次都失败,call_with_retry 抛出最后一次异常,熔断器的 _on_failure() 计数 +1。也就是说每一次 breaker failure 其实代表了 3 次真实失败。这样避免了临时抖动直接触发熔断。

追问 2:重试策略怎么区分”该重试”和”不该重试”的错误?

retry.pyis_retryable_http_error() 函数:

  • 该重试:timeout、连接错误(ConnectError/ReadError/WriteError)、5xx 状态码
  • 不该重试:4xx 状态码(客户端错误,重试没意义)、格式解析错误(数据问题,重试结果一样)

使用 tenacity 库,retry_if_exception(is_retryable_http_error) + wait_exponential_jitter(initial=0.5, max=4.0) + stop_after_attempt(3)

追问 3:HALF_OPEN 状态会不会并发多个探测请求?

当前实现没有严格限制——HALF_OPEN 时多个并发请求都会被放过作为探测。但因为项目是单线程 asyncio,await 之间不会有真正的并发,所以实际不会同时出现多个探测。如果是多线程场景需要加锁或者只放过第一个请求,但这里不需要。


Q15:FinOps 预算闸是怎么控制 token 成本的?#

回答:

token_budget.py 实现了会话级成本追踪与控制,分 soft/hard 两级:

追踪: 每次 LLM 响应后,charge_tree_usage()AIMessage.usage_metadata 里提取 input_tokensoutput_tokenscache_read_tokens,按定价表算成本:

cost = (non_cached_input × price_in + cache_read × price_cache + output × price_out) / 1_000_000
python

默认定价是 DeepSeek-V3 的费率:输入 $0.27/M、输出 $1.10/M、缓存读 $0.07/M。

去重:AIMessage.id 做幂等——同一条消息多次传入(比如 middleware 链多次处理同一个响应)不会重复计费。_seen: set[str] 记录已处理的 message id。

控制: budget_status() 返回三个状态:

  • "ok":未到阈值,正常执行
  • "soft":达到 TOKEN_BUDGET_USD × 0.8(观察信号,记录 metrics)
  • "hard":达到 TOKEN_BUDGET_USD(执行动作——在执行层拦下 COST_AMPLIFIER_TOOLS、回哨兵 _BUDGET_HARD_DENIED

COST_AMPLIFIER_TOOLS = FORK_TOOLS | RETRIEVAL_TOOLS | {"category_insight"} —— 拦的是最消耗 token 的工具(fork、搜索、RAG),迫使模型只能用已有结果做汇总或直接收尾。

和检索预算的协作: token 预算管”钱”,检索预算管”次数”。两者独立运作,任一超限都在harness 执行层回哨兵拦截tool_gates hook 各自判定)——工具表始终完整,不摘工具(保 prompt cache 前缀,见 M6.1 / Mperf.1)。

用户级 credit 配额: M19 上线了面向用户的 credit 配额体系(app/db/quota.py)。用户看到的不是美元而是整数 credit(1 credit = $0.001),默认每天 2000 credits。超额后 POST /api/task 直接拒绝。与 token_budget 的区别:token_budget 管单次任务的成本上限,credit 配额管单用户每天的总成本上限。

追问:为什么用 module-level dict 而不是 ContextVar?

retrieval_budget 一样的原因——ContextVar 子设父读不到。token 预算需要整棵调用树共享一个累计值(main + 所有 sub-agent 的成本加总),所以用 session_dir 作为 dict key,main 和 children 的 session_dir 相同,自然汇聚。


Q16:JWT 鉴权是怎么做的?解决了什么安全问题?#

回答:

问题: 之前 user_id 是前端直接传的(POST /api/task {user_id: "xxx"}),任何人都能伪造 user_id 读别人的偏好和历史——这是个越权漏洞

方案: JWT token 验证。get_current_user_id() 作为 FastAPI Depends 注入:

  1. Authorization: Bearer <token> 提取 token
  2. jwt.decode(token, secret, algorithms=["HS256"]) 验证签名
  3. 提取 sub 字段作为 user_id
  4. token 过期或签名无效 → 401

开发模式下支持 DEV_TOKEN——一个固定 token 映射到固定 user_id,方便调试。

thread 归属已实现(M16): JWT 验证了”你是谁”,claim_thread() 在首次 POST 时把 (thread_id, user_id) 写入 threads 表,assert_owner() 在后续 WS/cancel/files 请求中校验属主——拿别人的 thread_id 会被拒。数据库层 Thread.user_id 加了外键到 users.id。鉴权关闭时(demo 模式)这些校验直接放行。

追问:server.py 里还有哪些安全措施?

  1. 路径穿越防护safe_join(base, user_input) 检查 resolved path 是否在 base 目录内,防止 ../../etc/passwd 式攻击。thread_id 本身也是用户输入,所以 _safe_session_dir() 也做了 safe_join
  2. 文件上传限制MAX_UPLOAD_BYTES = 10MB,先 await file.read() 到内存再检查大小(承认这不能防 OOM 攻击,但 Starlette 底层有流式处理保护)
  3. 启动时验证validate_auth_config() 在 lifespan 里检查 JWT_SECRET 是否配置,没配置直接 fail-fast 而不是运行时才发现

八、可观测性#

Q17:你的可观测性体系是怎么设计的?#

回答:

三个维度:

维度 1:AGUI 事件流(面向用户)

monitor.py 定义了十余种事件类型(session_created、assistant_call、tool_start/end、summary_delta、items_preview、fork、queue_status、clarification_request、memory_updated、memory_applied、task_result、task_cancelled、error),统一 JSON 格式:

{"type": "tool_start", "event": "tool_start", "message": "正在搜索...",
 "data": {...}, "thread_id": "xxx", "timestamp": "..."}
python

事件通过 ConnectionManager.send_to_thread() 推给前端 WebSocket,前端展示为可展开的”思考过程”面板。

关键设计:子 Agent 事件不推给前端ActivityRecorder 绑定 root_thread_id,子 Agent 的 thread_id 不同,事件被过滤掉。用户只看到粗粒度的 fork 事件,不会被子 Agent 的内部细节淹没。

维度 2:结构化日志(面向开发者)

ContextVar 自动绑定 thread_iduser_idfork_depth 到日志上下文,不需要手动传参。用 bind_log_context() / unbind_log_context() 在 fork 进出时切换。

维度 3:Langfuse 链路追踪(面向性能分析)

可选集成 Langfuse(在线 tracing 平台),通过 apply_tracing(config) 挂到 LangGraph config 上。主 Agent 创建 trace(root=True),子 Agent 继承同一个 trace_idroot=False),所有 agent/tool 调用在 Langfuse 上形成完整调用树。可以看到每个 LLM 调用的 token 消耗、延迟、cache hit 率。

追问:你怎么衡量端到端性能?

run_agent() 入口用 time.monotonic() 计时,最终 elapsed_ms 写入 turn history 和 task_result 事件。配合 tree_snapshot() 的 token 统计(input_tokens、output_tokens、cache_read_tokens、cost_usd、model_calls),可以算出每轮的平均延迟和成本。

实测数据:端到端 295 秒,其中 96% 是 LLM 解码时间。这个发现直接导致了”子 Agent 用 fast LLM(关闭 reasoning)“的优化——子任务不需要深度推理,关掉 reasoning 后每个子 Agent 从 ~50s 降到 ~5s。


九、数据工程#

Q18:六个平台的商品数据是怎么清洗统一的?#

回答:

clean.py 实现了完整的 ETL 管线:

Step 1:平台多态映射

每个平台的 CSV 列名不同(Amazon 叫 product_title,Shopee 叫 name),PLATFORM_FIELDS 字典定义了每个平台每个字段的候选列名列表,按优先级尝试:

# 伪代码
fields["amazon"]["title"] = ["product_title", "title"]
fields["shopee"]["title"] = ["name", "item_name"]
python

normalize_row() 遍历候选列,取第一个非空值。

Step 2:文本清洗

clean_text() 做了一串处理:mojibake 修复(ftfy)→ HTML 反转义 → NFKC 正规化(全角→半角)→ 去 HTML 标签/URL → CJK 标点转换 → 去 emoji → 折叠重复标点 → 压缩空白 → 截断到 500 字符。

Step 3:质量门控

admit() 函数做严格校验:7 个必填字段(platform、item_id、title、price、currency、image_url、url)必须非空,price ≥ 0.1(过滤垃圾数据)。不合格的直接丢弃。

Step 4:去重

(normalized_title, round(price, 2)) 为 key。碰撞时比较 _completeness()(非空可选字段数),保留更完整的记录。

Step 5:可用性过滤

is_available() 按平台判断:Shopee 看 stock != "0",Walmart 看 available_for_delivery,Amazon 检查 "unavailable" not in availability

追问:科学计数法价格是怎么发现的?

跑完 ETL 后做数据质量报告,发现 Amazon 的价格分布异常——大量商品标价 1-3 美元,不合理。深挖发现是 2.29e+01 被错误解析为 2.29 而不是 22.9。修复后加了回归测试:assert normalize_price("2.29e+01") == 22.9。这种数据问题在 AI 项目里非常常见,所以每次改数据管线都要跑完整的质量审计。


十、评测体系#

Q19:你的评测框架是怎么设计的?怎么做到”零 GPU 迭代”?#

回答:

Rubrics as Rewards 框架——每条测试 query 动态生成评分细则,由 judge 模型自动打分。

三级评分标准:

  • P0 业务红线(一票否决):幻觉商品、编造 URL、死循环不收尾
  • P1 执行规范(扣分):没有比价就推荐、遗漏用户的硬约束
  • P2 质量打分(1-5 分):推荐理由质量、覆盖度、用户意图理解度

零 GPU 迭代闭环:

评测 → 定位 bad case → 改 prompt/工具/机制 → 再评测对照
plaintext

不做 SFT / RL / 微调(没有 GPU),但通过改 prompt 规则、工具参数、机制设计来解决 bad case。比如:

  • Bad case:“有足够信息但还在追问用户”→ 原因是 prompt 里鼓励澄清太激进 → 调整 prompt 加入”如果有合理默认值就别问了”规则 → 基线 32.8 → 修改后 52.8(+78%)
  • Bad case:“没调工具就直接 chat_fallback”→ 原因是 item_picker 输出后模型不知道下一步 → 在 item_picker 出口注入”下一步请调 shopping_summary”指令 → 通过率 +83%

judge 校准的教训:

评测早期发现 judge 模型有系统性假阳性——有三类:

  1. judge 认为”应该搜更多平台”但 agent 其实已经搜完了(信息不对称)
  2. judge 对”价格合理性”的判断标准和用户不一致
  3. judge 对”覆盖度”的理解和实际品类不符

解决方法:先校准尺子再改 Agent。给 judge prompt 加入更精确的评分锚点,用语义哈希做去重(同一 query 每次跑出相同的 rubric,避免 rubric 本身的随机性影响评测稳定性)。

追问 1:few-shot 注入实验效果怎么样?

诚实回答:没有增量收益。注入了精选的 do/don’t 示例到 system prompt,跑评测发现分数没变化。分析原因有三个:

  1. 天花板效应——核心指标已经 100%(做对的 case 就是做对的,加 few-shot 不能做得”更对”)
  2. 功能重叠——few-shot 示例表达的规则和 prompt 规则重复了
  3. 信噪比——query 变异度高,静态 few-shot 不可能覆盖所有变体

教训:新的优化手段必须瞄准现有能力的缺口,而不是加固已有覆盖

追问 2:多次运行的评测分数波动怎么处理?

跑多次取中位数,不是平均值。LLM 输出有随机性,单次分数不可靠。中位数比平均值对极端值更鲁棒。


Q19.1:一次对话是怎么被 Rubric 打成一个最终分数的?输入是什么,是把整段对话都喂进去吗?#

回答:

不是把整段对话原样喂进去,而是抽取 + 脱敏 + 重渲染成一段紧凑材料再交给 judge。我用一条真实种子 query 走一遍——q02_budget_trap_earbuds:「想要一副降噪蓝牙耳机,预算最多80美元,音质好点」,constraints={budget_usd: 80, category: 蓝牙耳机}

第一步,先跑 Agent 拿产出。 评测粒度是单 query 单跑:脚本对每条种子只调一次 run_agent(query),返回一个 run_result 字典,我只取三个字段:

  • final_text:Agent 给用户的最终回复正文
  • messages:这一轮完整消息列表(内部可能有很多次 Think→Act→Observe)
  • items:结构化商品卡列表

关键澄清:喂进去的是「一个用户 query 触发的那一整轮 Agent 内部编排」,不是多轮续聊对话。多轮续聊评测目前没做——种子集就是单轮意图,这是诚实边界。

第二步,从 messages 抽材料(三步压缩,不是原样倒),都在 app/eval/rubric.py

  1. extract_tool_calls:只从 AIMessage 里抽父 loop 的工具调用序列。fork 子 Agent 的调用在它自己 thread 的 messages 里、不进父序列——所以 judge 看到的正好是主 loop 的编排轨迹(P1 要评的对象)。
  2. render_trajectory:渲染成带序号的紧凑文本,单个入参截断 120 字符,并丢掉键名含 id 的参数(如 item_ids)。这是脱敏——否则 judge 会把轨迹里的内部 item_id 误当成「Agent 向用户泄露内部 id」判 P0 信息安全 fail。
  3. render_agent_output:拼成三段——【最终回复(信息安全红线只评这段)】 + 【商品卡(给用户展示)】 + 【内部执行日志·非用户可见·禁止据此判泄露】

所以真正喂给 judge 的 = 最终回复正文 + 商品卡 + 脱敏后的工具轨迹。system prompt、用户历史、工具返回的原始 payload、中间推理——都不进去

第三步,出分。 生成细则 + 逐条打分(judge 两刀,见 Q19.3)后,aggregate纯 Python 聚合成 RubricResult。q02 的真实结果:

  • P0 三条(价格/功能/品类红线)全 passed=true → 没触发一票否决
  • P1 三条(工具调用/信息完整/流程规范)全过 → 不扣分
  • P2 三条打分 3/4/4 → p2_avg=3.67

算分公式:P0 任一 fail → total=0;否则 total = (p2_avg/5)×100 − 10×P1违规数。代入:3.67/5×100 − 0 = 73.3。最终 total=73.3, overall_pass=true, is_high_score=true

输出RubricResulttotal(0-100) / overall_pass / is_high_score / p0_failures / p1_violations / p2_avg / 逐条 scores(每条带 rationale)。

追问:为什么不把工具返回的原始数据也给 judge? 一是灌爆 context(一次 item_search 返回几十条候选);二是 judge 评的是「给用户的成果」和「编排是否合规」,不是「中间数据对不对」——那是召回评测(recall@k,另一套确定性指标)的活。职责分离。


Q19.2:拿到分数后,怎么分流到 good case / bad case?#

回答:

代码里其实是两套阈值、两个用途,而且刻意不对称P1_PENALTY=10HIGH_SCORE_THRESHOLD=70)。还用上面 q02(73.3)和两条真实坏例对照:

① 高分轨迹(good case)—— is_high_score 判据:overall_pass 且 total ≥ 70。q02 的 73.3 命中。用途:喂给 scripts/eval/distill_fewshot.py 自动蒸馏成 few-shot 范例(飞轮第三腿),只有高分轨迹才配当范本。

② bad case —— 报告里现算run_rubric.py) 判据:not overall_pass 或 total < 60,按 total 升序排(最烂的排最前),打印「优先修」清单并带上破了哪些 P0/P1 维度。基线跑出的真实 bad case 长这样:

q08_web_search_review    0.0   P0破=价格红线/信息安全
q10_chitchat_weather     0.0   P1=工具调用/工具调用/卡片规范
q05_price_compare_samsung 3.3  P1=比价工具调用/运费计算工具/结论清单
plaintext

③ 中间灰带(60–70) —— 这是刻意留的缝:既不进 few-shot(不够亮眼),也不算 bad case(没烂到要修)。承认这个设计缝隙比假装它不存在更能体现你想清楚了。

④ 第三条分流:评测失败 ≠ 模型差 —— judge 解析失败、LLM 调用异常等 ok=false 的记录单独列,不计入模型表现均分,防基建抖动污染分数。

⑤ 分桶均分 —— 按种子集的 bucket 字段聚合(如「红线-预算」「多约束精挑」),定位「哪场景在掉分」,而不只是哪条。

分流结果全落 data/eval/rubric_report.json,供飞轮下一步检索 bad case / 沉淀高分范例。

追问:70 和 60 为什么不对称? 因为两条线服务两件不同的事:≥70 是「够好到能当教材」的招募线(宁缺毋滥,怕拿平庸轨迹污染 few-shot);<60 是「烂到该动手修」的报警线(60 出头的凑合 case 不值得占用迭代精力)。用一条线会逼你在「教材质量」和「报警灵敏度」之间二选一。


Q19.3:judge 模型具体怎么做最终评判?#

回答:

judge 出手两刀,都走 get_judge_llm()(更强模型 + temperature=0),都用 json_mode——因为本仓库 judge 走 DashScope/Qwen 兼容端点,不支持 tool_choice=required,只能用 json_object 模式,所以 prompt 里必须自带 json 字样和结构说明。

第一刀——为这条 query 定制尺子generate_rubric)。输入 query + constraints + intent,输出 6–10 条细则,分 P0/P1/P2 三档,具体到这条 query。q02 生成的真实细则:

维度判定
P0价格红线主体商品单价 > 80 美元即 fail
P0功能红线未明确具备降噪功能即 fail
P0品类红线不属于耳机类目(音箱/配件)即 fail
P1工具调用该调的检索工具没调则违规
P2音质覆盖 / 决策建议 / 场景洞察各写清 1 分与 5 分代表什么

这一刀带缓存:key = hash(query + constraints + intent + 生成 prompt 文本)。同一 query 细则只生成一次并复用——固定尺子刻度,否则每次重新生成细则会有方差,改 Agent 前后的分数变化分不清是「Agent 变了」还是「尺子抖了」。key 掺进 prompt 文本,是为了「改了评测口径 → key 自动变 → 缓存失效重建」,尺子定义没变就复用、变了就重建,两者都正确。

第二刀——拿尺子逐条打分score_against_rubric)。输入 query + 编号后的细则 + Q19.1 那三段材料,输出每条细则一条打分:P0/P1 给 passed(布尔),P2 给 score(1–5),每条都要写 rationale。q02 的真实 rationale 举例:P0-1 价格红线 → 「所有推荐商品单价均在 $21.99–$34.39 之间,未超 80」;P2-1 音质 → 「提及 HiFi 立体声但未解释驱动单元尺寸」给 3 分。

judge prompt 里烧进去的红线纪律(这套是踩坑校准出来的,是评测「不歪」的命门):

  1. 价格 P0 只在有明确 budget 时才设,单位一律 USD,不准凭空假设 / 折算人民币;
  2. 软偏好(便宜/小众/抗造)不得升级成 P0,至多当 P2;
  3. 信息安全红线只看最终回复正文,内部执行日志、到手价字段不算泄露;
  4. 用户明确要的仿品/大牌平替是正常需求,不准判合规 fail;
  5. 打分从严:证据不足宁可判不满足。

为什么需要这些纪律? 基线首轮均分只有 32.8,一堆 0 分——深挖发现大头是尺子歪了不是 Agent 差。三类系统性假阳性对上真实坏例:q08 被判 P0「信息安全」fail,是因为轨迹没脱敏、judge 把内部 item_id 当成泄露;同时被判「价格红线」fail,是 judge 给无预算的 query 凭空造价一票否决。q10(闲聊天气)被强加「该调检索工具」P1。方法论一句话:看 Rubric 0 分先质疑 judge,再质疑 Agent。 校准这些纪律 + 轨迹脱敏后,均分回到 52.8 一线(后续迭代到 round3 的 58.5)。

最后一步不调 LLMaggregate 是纯 Python——judge 只负责「逐条判定」,分数怎么加权、P0 怎么一票否决全在代码里算死,保证聚合逻辑可单测、可复现,把 LLM 的不确定性隔离在「判定」这一层。

追问:judge 会不会自己也不稳定?怎么保证「改 Agent 后分数涨了」是真改进而非方差? 三件套缺一不可:固定尺子(rubric 缓存,第一刀不再抖)+ 多跑取中位数(压第二刀打分的方差)+ 对照组验无副作用(另留一组本就满分的 case,确认改动没帮倒忙)。真实数据:「过度澄清」缺陷条 16.7→95、「口头收尾」10→93.3,对照组 100→100 全程不动。


十一、系统设计综合题#

Q20:如果让你重新设计这个系统,你会做哪些不同的决策?#

回答:

有三个地方现在看来可以做得更好:

1. 候选缓存的清理粒度

当前候选池以 session_dir 为 key 做了会话隔离(_REGISTRY: dict[str, dict[str, ItemCandidate]]),run_agent() 收尾时先 persist 到 JSON 再清内存,多会话不冲突。但长会话续聊时(同一 session_dir 多轮),旧轮候选和新轮候选混在同一个 dict 里,理论上旧轮的无关候选会干扰新轮的 enrich 回填(实际没出过问题因为 item_id 是唯一的,但不够干净)。更好的做法是按轮次分桶。

2. 事件回放应该支持完整的 event sourcing

当前的 Redis Stream 持久化 + replay_after 只支持”最近一次运行”的事件回放(MAXLEN=200TTL=6h)。如果要做完整的审计追踪或调试,应该支持任意历史会话的完整回放。但这需要更大的存储预算和索引设计,当前阶段没有必要。

3. 品类 rerank 覆盖率

item_picker 里做品类相关性 rerank 时依赖 category_insight 的 RAG 知识库。但知识库对长尾品类覆盖不全——比如”手机支架”这种细分品类在 RAG 库里找不到基准,rerank 退化为跳过。扩充知识库的 ROI 比优化算法高。

追问:系统里最大的技术风险是什么?

LLM 服务依赖。整个系统只有一个 LLM provider(通过 OPENAI_BASE_URL 配置),如果这个服务挂了,所有依赖 LLM 的工具(planner、shopping_summary、chat_fallback、image_understand)都不可用。当前有熔断器 + 指数退避做瞬时抖动容忍,但没做多 provider failover。这是个已知风险,等接入第二个 provider 后就能启用 fallback。


Q21:你在这个项目中做过的最有价值的性能优化是什么?#

回答:

子 Agent 关闭 reasoning(推理模式)

背景:实测端到端 295 秒,用 Langfuse 分析发现 96% 是 LLM 解码时间,其中大头是 reasoning/thinking token 输出。DeepSeek-R1 类模型在 thinking 阶段会产生大量中间推理文本,对复杂任务有帮助但对简单任务是纯浪费。

子 Agent 的典型任务就是”在某个平台搜一次 item_search”——一次工具调用,不需要深度推理。所以 get_fast_llm() 通过 extra_body={"enable_thinking": false} 关闭推理模式。

效果:每个子 Agent 从 ~50s 降到 ~5s。5 个平台并行 fork,总体从 ~50s(受最慢的子任务约束)降到 ~5s。加上其他优化(prompt cache、压缩),整体从 295s 降到可接受范围。

这个优化的关键洞察是:不是所有任务都需要最强模型。分层用模型(主 Agent 用推理模型做复杂决策,子 Agent 用快速模型做简单任务)是 AI 应用中投入产出比最高的优化手段。

追问:除了模型分层,还有哪些延迟优化?

  1. Prompt Cache——Cache Breakpoint 保证前缀稳定,cache hit rate 提升后输入 token 的处理时间大幅下降
  2. 工具侧限速——item_search 最多返回 20 条、剥离 URL/image_url 字段,减少传给 LLM 的文本量
  3. 并行 fork——5 个平台同时搜,Wall-clock = max(各平台延迟) 而不是 sum
  4. category_insight 语义缓存——同义词查询命中缓存,跳过 RAG 检索 + reranker 调用

Q22:这个项目的”诚实边界”有哪些?你怎么看待这些取舍?#

回答:

我在每个里程碑完成后都会明确标注做了什么和没做什么,这是工程诚实性的体现:

模块做了没做(诚实标注)
向量召回单编码器 BGE-M3 + payload filter不自训 embedding 模型(无 GPU);user 塔融合已删(零调用,改走偏好词并入检索词)
fork 隔离ContextVar + 独立 agent 实例未挂 LangGraph checkpointer(无跨进程恢复需求)
长期记忆SQLAlchemy/DB 统一后端(SQLite/PostgreSQL)语义裁剪 read_relevant 已删(量小全量注入即可)
JWT 鉴权验证用户身份 + thread→owner 资源级鉴权(M16)多租户数据面隔离未做
评测Rubric 动态打分 + bad case 修复不做 SFT/RL(无 GPU、无标注数据)
本地 fallbackn-gram hash 向量(256d,MD5 哈希)语义质量远低于真实 embedding
事件回放Redis Stream 最近一次运行不支持历史会话完整回放
credit 配额用户级每日 credit 额度(M19)按工具粒度的成本归因未做

为什么要标注这些?

  1. 技术面试——面试官最怕的是候选人吹嘘了一个自己没做的东西。主动说”我没做 X 是因为 Y”比被戳穿强一百倍
  2. 工程判断——知道什么不做和知道什么做一样重要。YAGNI 原则——checkpointer 随时可以加,但在当前需求下加它只会增加 Redis/Postgres 依赖和不被消费的持久化语义
  3. 下一步方向——每个”没做”都带有”什么时候该做”的判断标准。比如”多实例部署时需要 Redis Store”——需求出现时有明确的升级路径

追问:面试官可能会质疑”无 GPU 就不做训练”——你怎么回应?

两个层面:

  1. 工程层面——零 GPU 不等于零迭代。Rubric 评测 → prompt/工具/机制优化 → 再评测对照,这个闭环证明了可以在不训练的情况下持续改进系统质量(基线 32.8 → 52.8,+61%)
  2. 架构层面——系统保留了训练数据收集的接口(偏好 Store、turn history、评测结果持久化),如果将来有 GPU,可以用这些数据做 SFT/DPO。不是”不能做”,是”现在做的投入产出比不如优化 prompt 和机制”

十二、快速问答(高频基础题)#

Q23:asyncio.gatherasyncio.wait 的区别?你在项目中怎么用的?#

项目用 asyncio.gather 做并行 fork(parallel_dispatch_tool),用 return_exceptions=True 防止一个子任务失败导致其他子任务被取消。任何异常都被转为字符串错误信息,不中断整体。asyncio.wait 没有在项目中使用——它更适合”要做超时控制或先完成先处理”的场景,fork 场景是”等所有完成再汇总”,gather 更合适。

Q24:ContextVar 和 threading.local 的区别?#

ContextVar 是为 asyncio 设计的——它在 asyncio.create_task()复制父任务的上下文快照给子任务,每个 Task 有独立副本。threading.local 是线程级隔离,asyncio 的多个 Task 在同一个线程上运行,threading.local 无法隔离它们。

项目里 ContextVar 用于:thread_idsession_diruser_iduser_profilefork_depth

但有一个陷阱:ContextVar 的 set 是单向的——子 set 了不会回写给父。所以 retrieval_budgettoken_budget 不能用 ContextVar,改用 module-level dict(以 session_dir 为 key),main 和 children 读写同一个 entry。

Q25:Pydantic 的 model_dump()dict() 有什么区别?你在项目中怎么用?#

model_dump() 是 Pydantic V2 的方法(V1 是 .dict()),支持 excludeby_aliasmode 等参数。项目里 ItemRecordembed_text 字段标记了 exclude=True——调 model_dump() 时这个字段不会出现在输出里(它只用于 embedding 生成,不存入 Qdrant payload)。LocalFileStore 用 entry.model_dump_json() 序列化到 JSON 文件。

Q26:@lru_cache(maxsize=1) 在你的项目里用来做什么?#

单例工厂get_llm()get_fast_llm()get_judge_llm()get_tower_client()get_reranker() 都用 @lru_cache(maxsize=1)——第一次调用创建实例,之后返回缓存。好处是不需要显式的全局变量或单例模式,也避免了多次初始化(比如 HTTP 连接池重复创建)。


十三、进阶亮点题(容易拉开差距的点)#

Q27:用户需要补充信息时,Agent 怎么暂停等待用户回复?#

回答:

ask_user 工具实现了一个 asyncio Future 桥接模式——Agent 循环暂停在一个 Future 上,WebSocket handler 收到用户回复后 resolve 这个 Future,Agent 继续执行。

具体流程:

  1. Agent 调 ask_user(question="请问您的目的地是?")
  2. 工具内部创建一个 asyncio.Future,以 thread_id 为 key 存入 module-level dict
  3. 通过 monitor 推送 clarification_request 事件给前端(前端弹出输入框)
  4. 工具 await future,Agent 循环暂停
  5. 用户在前端输入回复 → WebSocket 收到 JSON {"type": "clarification_response", "text": "..."}
  6. WS handler 从 dict 里找到对应 Future,调 future.set_result(text)
  7. ask_user 工具从 await 返回,Agent 继续执行

安全机制:

  • 只有主 Agent 能调——检查 current_fork_depth(),子 Agent 调会被拦截(子 Agent 跟用户交互会很混乱)
  • 超时保护——默认 120 秒,超时后 Future 返回 fallback 文本(“用户未回复”),Agent 不会永远挂起
  • 重复请求处理——如果 Agent 在旧 Future 还没 resolve 时又调了一次 ask_user,旧 Future 被自动 cancel

追问 1:为什么用 module-level dict 而不是 ContextVar 存 Future?

因为 Future 需要在两个不同的异步上下文之间传递:Agent 循环在一个 Task 里,WS handler 在另一个 Task 里。ContextVar 在不同 Task 之间不共享,只有 module-level dict 能做跨 Task 通信。key 是 thread_id,保证不同会话的 Future 不会混淆。

追问 2:这个模式有什么缺陷?

  1. 单进程限制——Future 是内存对象,多实例部署时 Agent 在实例 A 等待,WS 请求可能打到实例 B,找不到 Future。需要用 Redis Pub/Sub 做跨实例桥接
  2. 不可持久化——如果进程重启,所有 pending Future 丢失。但这个风险可接受——用户 120 秒内没回复就超时,不太可能跨越进程重启

Q28:你的汇率转换是怎么做的?为什么不用实时汇率 API?#

回答:

fx.py 里维护了一个静态汇率表(约 20 种货币),所有转换通过 USD 作为枢纽货币

converted = amount * FX_TO_USD[from_currency] / FX_TO_USD[to_currency]
python

用静态表而不用实时 API 有三个原因:

  1. 确定性——同一商品每次转换结果一致,方便测试和调试
  2. 可用性——不依赖外部汇率服务,离线也能跑
  3. 精度需求低——这是购物推荐不是外汇交易,汇率波动 1-2% 对推荐结果影响极小

防御性设计: to_base_or_none() 在遇到未知货币或缺失金额时返回 None 而不是抛异常。批量处理时一条脏数据不会导致整批失败。

踩过的坑:Shopee 有 62% 的商品用 COP/MXN/CLP 等小众货币,最初汇率表没覆盖,导致这些商品的 price_usd 全是 None,搜索时 price_usd_max 过滤把它们全漏掉了。补了汇率表后解决。

追问:静态汇率表有过期风险吗?

有,但风险可控。购物推荐场景下,用户关心的是”大概多少钱”而不是精确到分。如果要做生产级的比价服务,应该定期(如每天)从 API 更新汇率表,存入 Redis 或数据库。当前是 MVP 阶段,静态表够用。


Q29:category_insight 的 reranker 短路逻辑是什么?#

回答:

category_insight 的检索管线有一个短路优化RERANK_BYPASS_MARGIN=0.30):

1. 粗排:OpenSearch hybrid 检索 top 30
2. 判断:如果 top1 和 top2 的分数差 ≥ 0.30 → 跳过精排,直接取 top_k
3. 否则:走 cross-encoder reranker 精排
plaintext

原理:如果粗排的第一名已经遥遥领先(领先 30% 以上),说明它的相关性非常确定,精排不会改变排名,可以省掉一次 reranker API 调用(省延迟 + 省成本)。

只有 1 条结果时也短路(没什么好排的)。但 2-30 条且差距不大时必须走 reranker——因为 OpenSearch hybrid 的粗排可能混入”相关但跑题”的噪声(比如搜”旅行箱”可能混入”收纳箱”),需要 cross-encoder 精细区分。

追问:reranker 的 local fallback 是什么?

Token overlap Jaccard 相似度。把 query 和每个 candidate 分词(英文按 [a-z0-9]+ 切词,中文按单字符切),算两个 token 集的 Jaccard 系数(|A∩B| / |A∪B|)。质量远低于真实 cross-encoder,但保证了:1)离线测试不依赖网络;2)reranker 服务挂了系统不断。score_detailed() 返回 (scores, used_remote) 元组,调用方可以知道用的是真实 reranker 还是 fallback。


Q30:前端的 WebSocket 状态管理有什么设计要点?#

回答:

前端核心是 useShoppingXTask hook,几个关键设计:

1. 多轮对话模型

  • 每轮(turn)是不可变的历史记录,只有最后一轮接收实时事件
  • 每个 turn 包含:query、finalAnswer、items[]、events[]、status、elapsedMs、tokens

2. Connect-first 实现

generateThreadId() → connectWS() → 等 ws_ready → POST /api/task
plaintext

前端主导 thread_id 的生成(UUID),不依赖后端分配。

3. 断线重连 WS 断开后重连时带 last_event_id(从 cmpStreamId() 计算最后收到的 Redis Stream ID),后端通过 replay_after() 补发缺失事件。前端用 Stream ID 比较去重——Redis Stream ID 是时间戳排序的<毫秒>-<序号>),天然支持”只要比这个新的事件”。

4. React StrictMode 防重 开发模式下 StrictMode 会 double-mount 组件。resumedThreadRef 防止重复添加”运行中”的 turn。

5. 会话持久化 会话列表存 localStorage,上限 60 条。第一轮的 query 作为会话标题(后续轮次不改标题)。

追问:为什么 thread_id 由前端生成?

因为 connect-first 协议要求先连 WS 再 POST。如果 thread_id 由后端 POST 返回,前端就不知道该连哪个 WS endpoint。前端先生成 thread_id,用它连 WS,再用同一个 thread_id POST task。


Q31:你的测试策略是什么?怎么测 Agent 这种非确定性系统?#

回答:

分三层:

第 1 层:确定性工具的单元测试

6 个不依赖 LLM 的工具(item_search、price_compare、shipping_calc、item_picker、web_search、category_insight)可以完全离线测试。mock 掉 Qdrant/OpenSearch/Tavily,验证过滤逻辑、排序逻辑、汇率转换、分数计算。

第 2 层:基础设施组件测试

  • test_concurrency.py:验证 TaskLimiter 的 try_acquire/force_acquire 行为、slot 计数、429 拒绝
  • test_clarification.py:验证 Future 创建/resolve/超时/cancel/重复创建
  • test_rubric.py:验证 P0 一票否决、P1 扣分、P2 聚合、扣分不低于 0
  • test_event_log.py:验证 Redis Stream 的 append/replay/TTL
  • test_circuit_breaker.py:验证三态转换(CLOSED→OPEN→HALF_OPEN→CLOSED)
  • test_semantic_cache.py:验证精确缓存 + 语义缓存命中/过期

第 3 层:端到端协议测试

M10 用 asyncio WebSocket client 测试完整的 connect-first 协议流程(连 WS → 等 ws_ready → POST task → 收事件 → 验证事件顺序)。测的是协议正确性,不是 LLM 输出内容。

对于 LLM 依赖的工具(planner、shopping_summary、chat_fallback):

用 monkeypatch 替换 get_llm() / get_fast_llm() 返回 fake model,测试的是”输入输出的结构正确性”而不是”LLM 回答质量”。回答质量由 M11 的 Rubric 评测体系覆盖(多次运行取中位数,对抗随机性)。

最优方案补充: 更成熟的做法是引入 snapshot testing——录制一次真实 LLM 响应作为 baseline,后续测试 replay 录制的响应而不是调真实 API。这样既保证了输入输出的真实性,又不依赖网络。可以用 VCR.py 或 pytest-recording 实现。当前没做是因为评测体系(M11)已经覆盖了端到端质量。


Q32:项目中的背压(backpressure)控制是怎么分层的?#

回答:

背压在两个不同层面有截然相反的策略:

外部请求层(PriorityRequestQueue)—— 快速拒绝 / 排队

PriorityRequestQueueapp/api/concurrency.py)限制同时运行的 Agent 任务数。try_reserve(kind) 返回 Reservation 或 None;返回 None 时 API 返回 429 + Retry-After。有位时返回 Reservation,但实际执行前可能还需 await wait_turn(res) 排队(优先级队列,按请求类型分池)。

endpoint 同步段只做「占位判定」(全同步无 await,单线程下原子),真正等槽在后台协程 _runner 里——这样 POST 立刻返回 thread_id,前端不用挂着等位。

内部 fork 层(Semaphore)—— 排队等待

asyncio.Semaphore(FORK_CONCURRENCY=5) 限制子 Agent 并发。超出时排队等待——不拒绝,等位

为什么排队?因为内部 fork 是同一个用户的同一个请求内部的子任务。拒绝自己的子任务没有意义,排队等释放才是正确语义。等待时间不算在子 Agent 的 90 秒超时里,但受主任务 300 秒总超时约束。

总结:外部拒绝/排队、内部排队,语义完全不同但各自正确。

追问:force_reserve 是怎么处理”同 thread 重新提交”的?

用户在同一个会话重新提问,前端用同一个 thread_id 发 POST。force_reserve() 先取消旧任务(task.cancel()),释放旧 Reservation,然后占用一个新 Reservation。这样用户不需要等旧任务超时就能重新开始。关键细节:旧任务的 _runner finally 块执行 task_queue.release(res) 释放 Reservation——三条退出路径(正常完成、异常、取消)都要正确销账。


十四、系统设计开放题#

Q33:如果这个系统要支持 1000 QPS,你怎么改架构?#

回答:

当前是单实例单进程,有几个瓶颈需要逐层解决:

第 1 层:水平扩展后端

  • 多个 Uvicorn 实例 + nginx/k8s LB
  • ConnectionManager 需要从进程内 dict 改为 Redis Pub/Sub——WS 连接在实例 A,Agent 在实例 B 时,B 发布事件到 Redis channel,A 订阅后推给前端
  • PreferenceStore 统一切到 Redis(LocalFileStore 多实例不一致)
  • Task 路由:同一个 thread_id 的请求要路由到同一个实例(sticky session),或者把任务队列化(Celery/RQ)

第 2 层:LLM 调用优化

  • LLM 是最大瓶颈(单次调用几秒)。1000 QPS 意味着同时可能有数千个 LLM 请求在 flight
  • 流量分级:简单查询(闲聊、单品搜索)用快速小模型,复杂查询(多平台比价)用推理模型
  • LLM 网关:引入 LiteLLM 或自建网关,做请求排队、负载均衡、多 provider failover

第 3 层:召回层扩展

  • Qdrant 从单节点改为分布式集群(Qdrant 原生支持 sharding + replication)
  • 或者 Qdrant 前面加 read replica(召回是只读操作)
  • embedding 服务做批量推理(当前一条 query 一次 API call,可以攒一批一起编码)

第 4 层:异步化

  • Agent 执行从 asyncio.create_task 改为消息队列(如 Redis Stream / Kafka),worker 独立消费
  • 好处是解耦——前端 POST 后不需要 worker 在同一个进程里

追问:你现在的架构最多能撑多少 QPS?

理论上 PriorityRequestQueue 的默认 slot 数决定了上限。假设每个任务平均 30 秒(含 LLM 等待),slot=10,吞吐约 0.33 QPS。瓶颈完全在 LLM 响应时间,不在后端代码。如果 LLM 响应缩短到 3 秒,同样 slot 数吞吐可到 3.3 QPS。


十五、新增能力题#

Q35:用户上传一张图,你怎么把它接入文本模型的 Agent 链路?#

回答:

主模型是纯文本(deepseek),图不能进主 loop messages——塞进去直接报错。解法是把图关在工具里:新增 image_understand 工具,内部调多模态 VL 模型(qwen-vl),把图翻译成结构化字段(品类/颜色/材质/风格/检索词),主 loop 只看文本结论。

关键发现:planner 是 Harness 在 abefore_agent 确定性预跑的。如果看图晚于 planner,「只发一张图不打字」的场景里 planner 拆不出任何字段,全链路空转。所以改成有图先看图、结论并入 planner。预跑消息形状与真工具调用同构(AIMessage(tool_call) + ToolMessage),AGUI 照常上报。

这不是以图搜图,是「看图说话再搜文本」——诚实标注。真正的跨模态检索需要 138 万商品全量下图 + CLIP 编码,无 GPU 跑不完。

追问:VL 调用的 token 怎么记账?

工具内部的 LLM 调用不经 agent middleware 记账路径。必须挂 usage callback + charge_tool_llm_usage,否则成本闸和每日 credit 配额完全看不到图片 token。code-review 抓出的。

追问:fork 子 Agent 怎么找到图?

上传目录按 session_dir 定位,不是 thread_id。fork 子 Agent 有自己的 thread_id,uploaded/<子tid>/ 不存在。session_dir 是继承父的(项目既有约定)。


Q36:用户说「旅行三件套,预算 300」,你怎么处理跨品类组合?#

回答:

planner 识别 bundle 意图 → 拆成多个品类槽位(各带独立预算/约束)→ parallel_dispatch_tool 按槽位批 fork 并行检索 → item_picker 内建 bundle 模式用 MCKP(多重选择背包) 在总预算下跨槽位组合优选。

为什么用 MCKP 而非简单排序? 跨品类组合有约束:总预算要覆盖所有槽位,每个槽选一件。简单按分数排会把预算全给一个槽。MCKP 保证每个槽至少选一件、总价不超预算、总效用最大。

Harness 和 fork 机制零改动——bundle 是 planner + picker 的能力扩展,不是新链路。符合同质 fork 硬约束。

确认用 ask_user 带可点选 options 发组成确认(单选/多选/默认勾选),用户可删槽 → planner 更新 → 按剩余槽补搜。

追问:槽位间有依赖怎么办?

当前 MCKP 假设槽位独立——诚实边界。跨槽依赖(尺寸兼容、风格统一)靠 planner 拆槽时把依赖写进各槽的约束字段(如「背包需 15 寸电脑仓」),让 item_search 在检索词里体现。


Q37:怎么做用户级的使用配额?已有的 token 记账不够用吗?#

回答:

已有两层记账都挡不住「同一个人连发一百条合规 query」:事后测量只看不拦,单任务成本闸只管单次爆炸。缺一把跨会话、跟人绑定的尺子。

按**成本(美元)**而非裸 token 计——prompt cache 命中的 token 单价只有 1/4,按 token 数记等于罚追问多的用户。对外换算成整数 credit(1 credit = $0.001),日额 2000 credits。

账本主键 (user_id, date_utc)单行汇总 O(1) 读。换天自然 upsert 新行,不需定时任务清零。

最关键的设计:最后一次任务的预算上限取 min(单任务预算, 今日剩余)——两把闸咬合,防止剩 $0.01 照样烧满 $0.50。

追问:取消任务账怎么算?

token 已经烧掉就必须记账。判定依据是「花没花钱」不是「任务成没成功」。记账跑在 shield 里不被取消信号打断。

追问:额度耗尽为什么不锁输入框?

Agent 会 ask_user 等用户回复。锁输入框 = 用户回不了话 → 等到超时 → token 照样烧。正确粒度:禁「起新任务」不禁「回复」。


Q38:账户体系为什么用 SQLite 不用 Postgres?#

回答:

单进程单 worker 是既定约束——任务表、并发队列、幂等窗口全是进程内状态,多 worker 会让取消和去重静默失效。不存在多进程并发写,Postgres 能提供的核心能力(高并发写、连接池)一条都用不上,换来的是一个容器 + 几百 MB 内存。

代价:多进程时锁表。但用了 ORM 不用手写 SQL,换 Postgres 只改一行连接串。这是「现在选便宜的,但别把自己焊死」。

会话认领是一次性的:首次出现绑定 user_id,之后永不更改。拿别人的 thread_id 来发消息直接拒——「谁最后发言归谁」会让攻击者一句话过户别人的会话。

追问:库文件放 data/ 行不行?

不行。生产编排里 data/ 是只读挂载(语料),SQLite 要写。放进去容器跑不起来。单独放 var/ 挂命名卷——漏了这步容器重建所有账号全没。


Q34:面试官总结式追问——你觉得这个项目最能体现你能力的点是什么?#

回答:

三个维度:

1. 系统设计能力——多层防护的设计哲学

整个项目的核心不是”用 LLM 做了什么”,而是”怎么让 LLM 可靠地工作”。四层安全兜底、机制优于 prompt、soft/hard 双级预算——这些是做过真实 AI 应用才会有的认知。只跑过 demo 的人不会想到这些问题。

2. 工程落地能力——从踩坑到制度化

每一个”最优方案”都来自真实踩坑:科学计数法价格、ContextVar 单向继承、CJK token 计数误差、弱模型不收尾。发现问题后不是打补丁了事,而是回溯根因、建立机制、加回归测试。比如 ContextVar 问题不只是改成 dict,还理解了”为什么 ContextVar 在这个场景不适用”并形成了选型原则。

3. 技术判断力——知道什么不做

诚实标注每个模块的边界(无 GPU 不做训练、checkpointer 暂不加、thread 资源鉴权留下一期),YAGNI 原则贯穿始终。每个”不做”都有明确的”什么时候该做”的判断标准,不是偷懒不做,而是当前投入产出比不值得做


全文完。每个回答以代码实现为准(2026-07-15 校准),追问链覆盖面试官最可能深挖的方向。共 38 题,覆盖 Agent 架构、工具体系、向量召回、RAG、压缩、记忆、通信、基础设施、数据工程、评测、综合设计、图搜、组合编排、配额、账户 15 大模块。