求职作品集的技术答辩底稿。每个回答都基于本仓库的真实代码 / 评测数据 / 踩坑记录,可追溯到
文件:行、commit、或 README「评测驱动优化日志」。配套:architecture.md(架构 3 分钟版)、dod-verification.md(17 项 DoD 验收)、runbook.md(运维处置)。
阅读顺序建议:架构决策(为什么这样选)→ 工程深度(怎么落地的细节)→ 实战教训(踩过什么坑、怎么定位的)。最后一节最值钱——它证明项目是真跑过、真调过的,不是 demo。
两层解决的是不同问题。内层 Modular RAG 解决「单次检索怎么检得准」——Pre-Retrieval(HyDE/Multi-Query/Decompose 按 intent 条件路由)+ 三路并发检索(BM25/Dense/Image)+ RRF 融合 + Reranker/压缩 + Self-Eval 循环(最多 2 轮)+ CRAG 兜底。外层 Agentic RAG 解决「这一轮该不该检索、检索完要不要调工具、答够了没」——由 AdvisorAgent 带工具循环驱动。
反例:纯 Modular RAG(无 agent)回答不了「5000 以内电池超 5000mAh 的手机」——这是结构化约束查询,向量检索召回的是「语义相近」而非「数值满足」。我让 Advisor 调 spec_query_sql 走 Postgres 双列存储(spec_value_text + spec_value_num)精确过滤。纯 agent(无 Modular RAG)则检索质量不可控、无法做 Self-Eval 重试。两层各司其职。
因为我要的是能在面试里讲清的设计,而不是一个黑盒依赖。自研版本是 4 个设计模式的协同:
LLMProviderProtocol = Port(依赖倒置,业务只依赖接口)AnthropicAdapter/OpenAICompatibleAdapter= Adapter,后者用 Template Method 把「构造请求体」做成抽象步骤,ConfigurableOpenAIAdapter(GPT/DeepSeek 共用)填具体ProviderRegistry+ DB 角色映射(llm_endpoints/llm_role_bindings)热重载 = RegistryFallbackChain= Chain of Responsibility(主 adapter 连续失败自动 fallover)
LiteLLM 能跑,但面试官问「你怎么做 provider 降级」「角色级路由怎么实现」时,我只能答「库内置的」。自研让我能答到代码行。代价是多写了几百行——对作品集这是收益不是成本。v1.1 起业主可在「模型设置」面板把任一角色(advisor/intent/vlm/judge)路由到 Claude/GPT/DeepSeek,密钥只进 .env,DB 仅存 env 变量名。
因为确定性维度交给 judge 会引入方差,浪费钱还不稳定。我的 5 维度里:
efficiency(latency + tokens)、process_quality(工具循环/grounding 规则)、task_completion、safety—— 规则打分,可复现、零成本、零方差output_quality(答得好不好,主观)—— LLM-judge
efficiency 是纯公式(agent_evaluator.py:latency ≤3000ms 满分线性罚到 60s;tokens ≤2000 满分线性到 20k)。这意味着「优化 efficiency」只有两个杠杆:降延迟、降 token——目标明确可控。如果交给 judge,同一次跑两遍分数能差 ±0.05(我实测过,见教训篇 Q14)。judge 只用在它不可替代的地方。
这是借鉴 GenericAgent 的 Distributed Prompt Storage 思路。Orchestrator(agent/orchestrator.py)是个纯状态初始化节点,根本不调 LLM——它只在每次运行开始写一次 state。领域知识不塞进一个巨型 system prompt,而是落在 docs/sops/*.md(手机推荐/对比协议/预算约束/影像建议/游戏推荐 5 个 SOP),AdvisorAgent 在复杂场景用 read_sop(name) 工具按需加载。
好处:① 上下文不被无关 SOP 污染(问游戏手机时不加载影像 SOP);② SOP 是 markdown,产品/运营能改,不用动代码;③ read_sop 带 trigger_conditions/exclusion_conditions 元数据(Rule #6),何时该调写得明明白白。
规格表是 3C 导购的核心数据,但它对 RAG 很难搞。设计上分三层,答辩时要诚实区分「真跑的」与「蓝图/descope」:① 解析主路当前是 Docling(在线摄入 admin_kb 真用它,parser_used="docling");MinerU(中文主路)与 Claude vision VLM 表格兜底在 Pre-M9 审计后 descope——当前 KB 是小规模精选 PDF,Docling 已够,VLM 兜底成本收益不成立,这是有意取舍不是没做完;② 大表做了行级 chunk + 模板化 Contextual 前缀的最小版(capability 已建有单测,摄入路径接线与完整 MultiVectorRetriever 父子结构 deferred);③ 结构化字段双列存储(spec_value_text 给人读 + spec_value_num 给 SQL 比)+ spec_query_sql 参数化查询是真实现——「预算内电池最大的手机」走数值过滤精确排序。三层里 ③ 是每天在跑的主力,① ② 讲清楚取舍理由反而是加分项。
核心是一个统一身份抽象——owner_id(api/auth.py:effective_owner_id),让「登录用户」和「游客」走同一套数据归属逻辑:登录请求 owner_id = JWT.sub(数据绑到真实账号),游客 owner_id = X-Owner-Id 头(匿名设备 id,维持原状)。这个设计的硬约束是「游客路径零回归」——之前所有按 X-Owner-Id 隔离会话/画像的端点,加了认证后行为不能变。所以 effective_owner_id 是「JWT 优先、否则回落 header」,没 JWT 时和过去完全一致。
技术栈:username + password,bcrypt 存哈希(绝不明文/不进日志),register/login 签发 HS256 JWT(claim sub/username/role),客户端带 Authorization: Bearer <jwt>,新依赖只加 pyjwt + bcrypt。RBAC:require_admin(api/admin_kb.py)Bearer admin JWT 优先 + 旧 X-Admin-Token 后备——为什么保留后备?因为现有管理 UI 在迁移期还在用 token,先上 RBAC 不破已有客户端,token 路径语义完全不变(ADMIN_TOKEN 未设→503、设了但不匹配→401)。
几个刻意的边界决策值得讲:① 首位注册=admin 且在事务内 count(*) + INSERT 原子完成——否则两个并发首注册都读到 count==0、都成 admin;外加 unique 约束兜底竞态。② login 统一返回 401(用户不存在和密码错同一句 invalid credentials)防用户名枚举。③ JWT_SECRET 空 → register/login 直接 503 AUTH_DISABLED、decode_token 一律返 None——认证要么真启用、要么明确禁用,不留「半启用」的模糊状态。④ bcrypt 有 72 字节硬上限,hash 和 verify 两端一致截断到 72 字节,长密码确定性处理而不是在 verify 时崩。
我对「跨端」做了精确切分,不搞一刀切:
- 会话历史 = 各端各自隔离,不跨端。web 会话走后端按
owner_id隔离,身份切换时chatManager.reset()+ 丢弃["conversations"]查询缓存重取(app/page.tsx订阅onIdentityChange);iOS 会话是本地按 owner 分文件存(ConversationStore.fileURL:游客沿用旧conversations.json零丢失、账号写conversations_<owner>.json),登录/登出switchOwner先 flush 再 load。正因为 iOS 会话是本地存储,「会话跨端同步」对它不适用——这是有意的,不是缺陷。 - 画像 / Langfuse trace = 跨端归一。它们都以同一个
owner_id为键写后端,所以同一账号在 web 提的偏好、在 iOS 也生效;trace 也归到同一身份下。
token 落点也按端选型:web 存 localStorage(zustand persist 只持久化 token,user 对象每次 /me 重新 hydrate,role/username 永远不和服务端漂移),iOS 存 Keychain;两端都用 6 个 {isAdmin && ...}/if auth.isAdmin 入口做客户端角色门控(真正的权限在后端 require_admin,前端只是不渲染)。
step / input / output / model / tool_name / tokens_in / tokens_out / latency_ms / error / status(observability/span_hook.py 的 SpanFields)。强制是因为少任何一个,某类问题就查不了:没 latency_ms 找不了性能瓶颈、没 tokens_in/out 找不了成本热点、没 error/status 看不出降级。这是 CLAUDE.md Rule #2 的硬约束,监控、CI 报告、生产日志三端靠这套字段对齐。
这是 P3 的核心。问题:运行时 emit_cards 的白名单一度是「evidence ∪ 所有 tool_calls 输入(含失败调用)」,比评测 F5 规则(cards 必须 ⊆ evidence)和 LLM-judge 都宽——模型钻这个政策裂缝,把检索没召回但 KB 里存在的产品(如 xiaomi-17-pro-max,KB 有 8 chunk)经一次工具调用就「洗白」进卡片。
解法是证据增广(agent/tool_evidence.py):成功的 get_product_details/spec_query_sql 结果回写 evidence channel(source="tool" 的 Evidence),然后 emit_cards 收紧到「仅 evidence」。这样运行时白名单、评测 F5、judge 看到的是同一个 grounding 口径。配合 LangGraph 不变量(节点必须 RETURN 新 list 作 delta,不能原地 mutate——见教训篇 Q12),下一轮 advisor 迭代能看到增广后的证据。结果:编造类失败案例从评测里基本清零(baseline 0.789→0.838)。
可用性优先于计费精确性。billing/cost_guard.py 的 add/monthly_spend/exhausted 全部捕获 Redis 异常后放行(log warning)。如果 fail-closed(Redis 挂就拒绝服务),等于让一个计费辅助系统的故障拖垮主链路——这是反模式。fail-open 的代价是 Redis 宕机期间月累计少记,恢复后管理员可手动调预算补偿。同理 from_url 加了 socket_connect_timeout=2——Redis 黑洞时启动最多等 2s,不违反「启动不被外部依赖 gate」(eng-m0 C8)。
P1 解决 error 类用例发空/纯空白输入直接把空 content 塞给 LLM API → provider 400 崩溃。三层(深度防御,每层独立兜底):
- graph 入口短路:
_route_after_orchestrator检测空/空白user_query→ 路由到clarify_node(固定澄清话术,零 LLM,流式返回),绕过 intent 这第一个 LLM 节点 - adapter 出站守卫:
ensure_nonempty_user_content在打网络前拒空 content(LLMContentError,不 retry 不 fallback)——任何代码路径漏过第一层,这层挡住 - schema 校验:
UserInput.textfield_validator 纯空白 → 422
单层不够,是因为空输入可能从多个入口进来(API 请求、多轮 history、未来新节点)。三层各守一个边界。实战效果:error_005("")/error_103(" ")的 task_completion 从 0→1.0。
节点必须 RETURN state delta 作为新对象,原地 mutate 永远不进 channel(finding-024)。这个 bug 让 agent 工具调用循环从 M2b 起从未真正执行过——async-generator 风格原地改 state,LangGraph 的 channel 收不到,工具循环空转,但早期评测分数虚高(因为根本没跑工具就「答」了)。修复后(commit b3d0118)才跑出第一个有意义的 baseline 0.712。教训:评测基础设施和被评系统一样需要工程验证——分数好看不等于系统在工作。
因为这俩需求不一样,硬塞一个字段会两头不讨好。Langfuse trace 的 user_id 是展示身份(在 Langfuse Users 页给人看的),我设成 acct:<username>(登录)/ guest:<device-id>(游客)/ None(都没有);而 owner_id(conversations/profiles 的真实键)是数据归属,仍用那个稳定 id(api/agent.py:124)。
如果把 owner_id 直接当 user_id 用,Langfuse Users 页里全是不可读的设备 hex,账号和匿名流量也分不清;反过来把 acct:<username> 当数据键,用户改名就丢历史。拆开后:① 排查问题时按 acct:/guest: 前缀一眼看出是注册用户还是游客;② 数据归属不受展示名变动影响。这是「给人看的 id」和「给系统用的 id」该分离的一个典型例子。
这一节是项目「真跑过」的证据。每条都有评测数据或 commit 支撑,对应 README「评测驱动优化日志」。
P3 efficiency+grounding 是完整一例。信号:全量评测 efficiency 0.514 最弱 + judge 在 edge_002/003、regression_005 点名「编造商品」。根因(见 Q9):三方 grounding 口径不一致。改动:证据增广统一口径 + 5 轮 prompt 迭代。量化结果:weighted 0.789→0.838,process_quality 0.876→0.966,最差案例 edge_003 0.384→0.844、edge_004 0.403→0.856。全程记在 README 优化日志表,每轮一行(触发信号→改动→实测前后分)。这就是「可观测+可评估=持续优化闭环」的实证,不是口号。
两次,都很有教育意义。
一次是假退化:某次全量 GATE FAILED(0.717),看着像质量崩了。但我做了数学拆解——0.717×71=50.9,扣掉 6 个 0 分案例,其余 65 例均分 0.783 ≈ 持平 baseline。根因是机器睡眠:跑评测时 macOS 休眠掐断了在飞的 LLM 连接,造成 4 个假 ReadTimeout(日志时间线有 7 小时断层)。教训:评测基础设施需要工程化——此后所有评测用 caffeinate -is 包裹,污染数据绝不更新 baseline。
一次是虚高:finding-024(Q12)——工具循环从没真跑,但早期分数好看。这俩合起来让我对「分数」有了敬畏:先确认系统真在工作,再信分数。
有,P3 第 3 轮就是。我加了「每款候选必须覆盖」的指令想提分,结果 edge_004 0.849→0.680——模型为了「覆盖」给未深查的款编造规格,反而触发 judge 的「编造风险」评语。三轮对照(无覆盖要求 0.849 / 强覆盖 0.680 / 弱覆盖 0.679)证明覆盖性指令本身伤害这个 case。第 5 轮我把它回退到原文。教训:① 8 例子集的 judge 方差 ±0.05,连续微调会过拟合,评语收敛即停,裁决交全量;② 每轮 prompt 改动单独 commit,可回滚——这次就靠它退回去了。
评测规则常数是 prompt 设计的硬边界,这点很反直觉。例子:process_quality 的 SAME_TOOL_CAP=3(同一工具调 ≥3 次扣 0.4 分)。我一开始在 advisor prompt 写「至多 3 款调 get_product_details」——结果恰好踩在 3 这条罚分线上。必须把 prompt 改成「≤2 款」才避开。还有 MAX_ITER=5:工具循环 5 轮把收尾的 emit_cards 挤出迭代上限,导致 cards 为空、卡片交付失败。所以 prompt 里写「工具总数 ≤4,给 emit_cards 留位」。结论:写 agent prompt 前先读评测规则的常数,否则在跟自己的评分器打架。
judge 有可见性盲区——它只看到 evidence / tool_calls(无输出) / final_answer / cards,看不到工具输出里的规格数字。所以来自 get_product_details 输出的规格会被判「疑似编造」。P3 验收后仍有 3 例此类评语(edge_002 经终态 dump 证伪——product_id 确在 evidence)。我的处理:① 不盲信 judge,对存疑案例做终态 dump 实证;② 记入 backlog(候选修复:tool-evidence snippet 嵌入关键规格摘要,让 judge 看得到)。诚实记录「零编造目标未完全达成」,而不是粉饰。
抽屉功能页原先从底部弹 sheet/fullScreenCover,体验割裂。我改成整页 push 导航(enum MenuRoute + NavigationPath + navigationDestination,9 个页删掉自定义 SheetHeader),让功能页天然获得系统的「左缘右滑返回」。
坑在于两套边缘手势会打架:聊天 root 的左侧抽屉是「左缘起手」打开的,系统的「左缘右滑返回」也是左缘起手。关键决策是 NavigationStack 必须放在抽屉容器的最外层(ChatView.swift:32),不能放抽屉内层——放外层后,只有 root 屏有抽屉边缘条,push 进去的功能页由系统导航控制器接管返回手势,两者物理隔离在不同层级,互不抢手势。放内层的话,push 页会同时挂着抽屉手势和系统返回手势,左缘一划行为不可预测。这种「手势归属由视图层级决定」的细节,是原生端不同于 web 的地方。(顺带:评测候选详情用 navigationDestination(item:) 做 push,EvalCandidate 为此加了 Hashable,详情 pop 时自动清空选中项。)
这是个很好的「端到端数据流」案例。业主反馈:手动采纳入库的失败案例,数据比 synth 合成案例稀疏——synth 案例带 category/intent_type/preferences 等 expected_slots,采纳案例却几乎是空槽。
定位根因是端到端丢数据:挖掘候选时(done_hook)只存了 query/final_answer,把 agent 当场已经算出来的 intent_type / 抽取槽位 / 调用过的工具全丢了;于是采纳时(approve)无米下锅,只能写死空 expected_slots。这不是某一处的 bug,是数据在 4 层之间断流。
修复贯通 4 层:① 表加 signals JSONB 列(init.sql + main.py lifespan ADD COLUMN IF NOT EXISTS 幂等补,兼容已建库——和 users 表同一个坑);② done_hook _build_signals 把 intent_type/extracted slots/tools 写进 signals;③ approve 时 _signals_to_expected_slots 读 signals、构建 expected_slots + 富 notes(intent=.../tools=...);④ gold-set writer 落盘。
取舍点值得讲:expected_slots 用的是客观抽取值(agent 真算出来的槽位,安全可信);但 expected_safety 坚持不捏造——我不会凭空给一个采纳案例编造「应该拒答 X」之类的安全期望,那等于污染评测基准。客观信号可富化,主观期望宁缺毋滥。(修复前已入库的候选自动降级处理,加 1 个后端测试覆盖。)
如实列:① eval gate 未在 CI 自动触发(账单未恢复,workflow_dispatch 手动 + 本地 caffeinate 跑,一行 trigger 即可启用,见 dod-verification.md 第 16 项);② judge 可见性盲区致 3 例假「编造」评语(Q18);③ 流中途异常的请求成本可能漏记(方向是少记,不会误熔断);④ 成本 Gauge 单 worker 语义,多 worker 需 prometheus multiprocess mode。这些都不阻断作品集演示,但我清楚它们在哪、怎么修——知道自己系统的边界,比假装没有边界更可信。
raguide 是个 3C 导购 Agent,技术骨架是 Agentic RAG 外层套 Modular RAG 内层、Orchestrator 调度 3 个 sub-agent。它的差异点不在「用了什么模型」,而在工程化程度:75 例 5 类别 gold-set + 5 维度混合评测 + Langfuse 9 字段 trace + Prometheus 告警 + 成本熔断 + runbook,外加 JWT/RBAC 认证 + 统一身份(同一 owner_id 抽象兼容登录与游客,游客路径零回归)+ web/iOS 双端。测试总量后端 922 / web 351 / eval 57 / kb 45(内含认证 31 测)。每一次优化都有评测数据支撑、记在优化日志里,baseline 从 0.712 一路调到 0.838,全程可追溯。它不是一个 demo,是一个真跑过、真调过、知道自己边界的系统。