M0 · 项目全景与架构串讲#
简历 Bullet Point: 设计并实现对话式全球购物 Agent(138 万商品 / 6 平台),采用 Think→Act→Observe→Reflect 有状态循环 + 同质 fork 并行 + Harness Hook Pipeline 治理框架 + 四层终止机制 + 意图驱动能力菜单,零 GPU 条件下通过 Rubric 评测驱动迭代,端到端延迟从 83s 压到 40s
开场钩子#
场景#
项目早期的一次端到端测试,用户 query 是”旅行三件套,预算 300”。主 loop 判断需要跨 5 个平台并行搜,调 parallel_dispatch_tool 派了 5 个子 Agent。
前三个子 Agent 正常——搜完一轮候选就返回了。但第四个子一直在循环:搜了一轮觉得不够,换个关键词再搜,搜完又觉得不好,再换……它在 item_search 上转了 7 圈。第五个子更离谱——它搜完候选之后,自己调了 shopping_summary,输出了一份格式完美的购物推荐:“根据您的需求,我为您精选了以下 3 款旅行用品……”。问题是这个子 Agent 的职责是返回原始候选给主 loop 合流,不是自己做推荐。主 loop 收到这份”候选”后傻了——它期望的是一个 JSON 列表,拿到的是一段自然语言文案。
5 分钟超时到,整个请求被杀掉,用户看到一个空白页面。
这次事故逼出了后来整个项目最核心的设计原则:对 LLM 的行为约束,prompt 是辅助,机制才是兜底。
面试官切入#
“你简历上写了’四层终止机制’和’Harness 治理框架’——能先介绍一下整个项目,然后讲讲这些是怎么来的?“
一、模块运作流程#
1.1 一句话定位#
ShoppingX 是一个对话式全球购物 Agent:用户用自然语言说购物意图,系统跨 6 个电商平台并行检索、比价、算关税运费、按偏好精挑,最后给出一份带购买理由的商品卡清单,并把用户偏好沉淀为跨会话长期记忆。
1.2 全景流程图#
用户消息
│
▼
FastAPI /api/task(POST,后台 create_task)
│
├─ JWT 鉴权(校验 sub,绑定 user_id)
├─ 幂等去重(同 thread 同 query → already_running)
├─ 双池排队(normal 5 槽 / heavy 3 槽,按历史轮数分流)
│
▼
run_agent() 入口
│
├─ 建会话目录 + thread_scope 绑 ContextVar
├─ Harness on_session_start(阶段机复位等)
├─ 读 Store 注入长期偏好 + 行为历史 + 会话级 P_t → 拼进当轮 human message
├─ create_agent(FULL_TOOL_SET, system_prompt, middleware)
│
▼
主 AgentLoop(Think → Act → Observe → Reflect 循环)
│
├─ Think: 模型拆解意图,判断需要什么工具
├─ Act: 调工具(planner / item_search / price_compare / ...)
│ ├─ 若需跨平台并行 → dispatch_tool fork 同质子 Agent
│ │ └─ 子 Agent = 完整克隆(同工具集 / 同 prompt / 同中间件)
│ │ └─ 独立 thread_id + 独立 agent 实例,安全四层兜底
│ └─ Harness Hook 全程介入:
│ pre_tool_call → 阶段门 / 安全白名单 / 熔断器
│ post_tool_call → Schema 断言 / 截断 / 循环检测
│ post_reflect → 漂移检测 / 阶段转移
├─ Observe: 读取工具返回
├─ Reflect: 够了 → 调终结性工具收尾;不够 → 回到 Think
│
▼
终结性工具被调用(shopping_summary 或 chat_fallback)
│
├─ Harness on_session_end(输出审核 / 脱敏)
├─ items_preview 事件下发(商品卡先出,文案随后流式补)
├─ summary_delta 流式文案 → WebSocket → 前端逐字渲染
├─ 落产物到 output/<thread_id>/(summary.md + result.json + turns.json)
├─ 记忆管家 curator 扫本轮 → 更新 P_t + 提升长期偏好
│
▼
用户看到商品卡清单 + 购买理由plaintext1.3 分步详解#
第一层:主 AgentLoop
基于 LangGraph 的 create_agent 封装,Think→Act→Observe→Reflect 四阶段有状态循环。不写死步数,模型在 Reflect 阶段自判信息够了就调终结性工具(shopping_summary 或 chat_fallback)收尾。TERMINAL_TOOLS = {"shopping_summary", "chat_fallback"},LangGraph 检测到这两个工具被调用后不再调度下一轮。
为什么不在调完终结工具后硬插 → END 跳出?因为还要留最后一轮让模型把结构化结果转成面向用户的收尾文案。自然终止比硬跳更稳,也不丢这段文案。
意图驱动能力菜单:早期版本 system prompt 写死工具调用顺序,每次请求不管意图都全链路跑一遍。重构后 planner 工具解析意图输出 tasks(如 ["category_insight", "chat_fallback"] 或 ["item_search", "price_compare", "shipping_summary"]),模型按 tasks 组合工具子集。品类行情走快捷路径两步出结果,完整购物才走全链路。
第二层:同质 fork
当需要跨平台并行搜索时,主 loop 通过 dispatch_tool 派出子 Agent。关键词是同质:子 Agent 是主 Agent 的完整克隆——同样的 13 个工具(9 大业务工具 + ask_user + forget_preference + dispatch_tool + parallel_dispatch_tool)、同样的 system prompt、同样的中间件栈。fork 三件事判断(满足任一即 fork):① 能并行 ② 要隔离 ③ 链够深。
子与父的四点不同:① LLM 档位——子用 get_fast_llm() 关闭 reasoning ② thread_id 独立——sub-{uuid}-d{depth} 隔离对话历史 ③ session_dir 继承父的——产物归同一会话目录 ④ 执行层权限——depth_gate 在 depth≥1 时硬拦聚合工具和上下文工具。能力同质但授权不同质。
第三层:fork 安全四层
① 深度闸:MAX_FORK_DEPTH=1,子想再 fork 孙直接拦掉(ContextVar 跟踪) ② 超时 + 迭代上限:子 Agent 90s 超时 + 最多 6 次迭代 ③ 结果截断:回传 JSON 超 4000 token 就截断 ④ 循环检测:滑动窗口 6 次调用里同一工具出现 4 次触发收尾催促。
治理框架:Harness Hook Pipeline
6 个 Hook 点覆盖 Agent 全生命周期——on_session_start、pre_think、pre_tool_call、post_tool_call、post_reflect、on_session_end。所有运行时管控(截断、熔断、断言、漂移检测、阶段门、终结纪律、预算闸、输出审核)都是独立函数通过 @harness_hook 装饰器注册。新增一道检查 = 写一个函数 + 一行装饰器,不碰主流程代码。当前注册了 15+ 个 Hook 模块。
阶段状态机
四阶段 PLANNING → SEARCHING → COMPARING → CONCLUDING,每阶段只放行对应的工具子集。模型在 PLANNING 阶段想调 item_search 会被执行层哨兵拒绝。为什么不直接摘掉工具?因为摘工具会改 system prompt 的内容,破坏 prompt cache 的前缀匹配——工具表恒定、执行层拦截,前缀稳定。
召回管道
BGE-M3 编码(query + 偏好词一起编码)→ Qdrant dense 向量搜索 + 多维 payload filter(平台 / 价格 / 评分 / 品牌排除)→ BGE-Reranker cross-encoder 精排。个性化走「偏好词并入检索词 + payload filter」(向量级 user 塔融合已删,长期零调用)。跨语言靠 BGE-M3 的多语言能力。
品类知识库——两段式检索
category_insight 采用 resolve_category(品类投票确定目标品类)+ fetch_cards(按 resolved 品类精确取卡)的两段式管道,避免一次性语义搜索把不相关品类的卡片混进来。
记忆体系
长期偏好通过会话结束后独立运行的记忆管家(curator)扫本轮对话提取 like/dislike 双极偏好沉淀到 Store,下次对话自动注入。会话级短期偏好 P_t 逐轮累积约束(预算/材质/颜色),由 item_picker 机制性强制执行——硬 dislike 走确定性过滤、预算走数值比较,不依赖模型转述。记忆 schema 用 domain/slug 派生 dedup_key(废弃手拼 key),P_t 扩展 current_intent/slots/rejected_options/open_questions/decisions_made 五个结构化字段。
可观测性
Langfuse 全链路 trace(每个工具调用 + 每次 LLM 请求),AG-UI 事件流 + WebSocket 实时推送(十余类事件),Prometheus metrics(工具 RT P95 / 成本 / 熔断),Rubric 评测分数回注 trace,工具 RT 告警(P95 滑动窗口 + 三态状态机)。
1.4 数据流 trace:一条 query 端到端走一遍#
以 “想买便宜又抗造的旅行三件套,预算 300,不要塑料的” 为例。
Step 1 → planner(意图拆解)
- LLM 提取结构化字段 →
PlanOutput tasks: [recommend, price_compare, landed_cost],category: "旅行收纳",budget_amount: 300,currency: "CNY"(系统规则回填),hard_constraints: ["不要塑料"],soft_preferences: ["便宜", "抗造"]
Step 2 → dispatch_tool(跨平台并行 fork)
- 主 loop 调
parallel_dispatch_tool,fork 出 3 个同质子 Agent(amazon/shein/shopee)
Step 3 → item_search × 3(子 Agent 内执行)
- BGE-M3 编码(query + 偏好词)→ Qdrant dense + payload filter(platform / price_usd_max=42.86 / min_rating)→ BGE-Reranker 精排
- 每平台返回
ItemSearchOutput(~20 条候选)
Step 4 → price_compare(跨平台比价)
- 汇率归一
to_base(price, currency, "USD"),按price_usd升序排
Step 5 → shipping_calc(到手价估算)
- 收货国四层解析(用户明示 → 偏好 → 语言推断 → 默认 CN)
- 查运费表 + 关税率 + 免征额 →
landed_usd,国内单免税
Step 6 → item_picker(偏好精挑)
- 硬过滤:dislike-hard “塑料” → 确定性淘汰
- 预算过滤:
landed_usd > 42.86的淘汰 - LLM 打分:按软偏好评分 + 写 pick_reason
rejected_options机械灌入 P_t(不经 curator 猜测)
Step 7 → shopping_summary(终结,出清单)
- items_preview 事件先推商品卡(先出货、后出文案)
- LLM 生成 summary 文案 + 前 3 件 reason 重写
- summary_delta 流式下发 → 前端逐字渲染
Step 8 → 收尾后处理
- 落产物:summary.md + result.json + turns.json
- report_task_result → WebSocket → 前端
- curator 扫本轮 → 提取”不要塑料” → 沉淀长期 Store
1.5 技术选型决策表#
| 组件 | 选了什么 | 为什么选它 | 什么情况下换 |
|---|---|---|---|
| Agent 框架 | LangGraph create_agent | LangChain 生态统一;原生支持 middleware 和 recursion_limit | 需要更细粒度图控制(条件分支/审批节点)→ StateGraph |
| 向量库 | Qdrant | 原生 payload filter(价格/平台/评分);单节点开箱、分布式原生 | 超千万 → Milvus;纯内存无 filter → Faiss |
| RAG 知识库 | OpenSearch | Hybrid Query(KNN+BM25);english 分词 | 已有 ES → 直接 ES;轻量 → pgvector |
| Embedding | BGE-M3(SiliconFlow) | 多语言;1024 维性价比高;API 兼容 OpenAI | 更高精度 → text-embedding-3-large |
| Reranker | BGE-Reranker-v2-m3 | 多语言对齐;API 无需本地 GPU | 低延迟 → 本地模型 |
| LLM | DeepSeek / Qwen(OpenAI 兼容 API) | 中文能力强;支持 reasoning 开关 | 更强推理 → GPT-4o / Claude |
| 偏好存储 | SQLite(默认) | 零依赖;接口与 Redis 同构 | 多实例 → 切 Redis |
| 账户库 | SQLAlchemy + Alembic | ORM + 可追踪迁移 | 分库分表 → 换中间件 |
1.6 口述脚本#
面试官:“介绍一下你的项目。”
30 秒 · 项目是什么
“ShoppingX 是一个对话式全球购物 Agent。用户用自然语言说需求,系统跨 6 个平台并行检索、比价、算关税运费、按偏好精挑,给出带理由的商品卡清单。数据 138 万条商品,技术栈是 FastAPI + LangGraph + Qdrant + OpenSearch + BGE-M3/Reranker + Langfuse 可观测 + WebSocket 实时推送。已公网部署,支持注册和在线体验。”
1 分钟 · 为什么是 Agent 不是 Workflow
“早期版本是固定流水线。问题是用户说’最近什么旅行包比较火’只需查品类知识库就够,但 Workflow 全链路跑一遍。改成 Agent 后 Think 阶段自己判断调什么工具。后来进一步重构成意图驱动:planner 解析意图输出 tasks,模型按 tasks 组合工具子集。一句话:工具次序没有一行代码强制。”
1 分钟 · 三个技术亮点各 20 秒
“亮点一:机制兜底不靠 Prompt 祈祷。每个行为问题都有代码级硬约束——四层 fork 安全、阶段状态机、检索预算、token 预算闸。”
“亮点二:Harness 统一治理框架。修 bad case 不是修 prompt 是修 Harness。6 个 Hook 点,新增检查 = 加一个文件 + 一行装饰器。”
“亮点三:延迟减法。端到端从 83s 压到 40s,主 loop 从 9 轮压到 5 轮。核心洞察:96% 延迟是 LLM 解码——子 Agent 关 reasoning、planner 确定性预置省一轮、收尾文案不逐件重写理由。”
30 秒 · 诚实边界
“数据来自 Kaggle 公开数据集,没有真实平台 OAuth/支付/物流。没有 GPU 训练/微调。评测是离线跑。进程内状态,多副本需迁 Redis。“
二、踩坑实录#
坑 1:Agent vs Workflow 不是”自由度高就好”#
- 现象:早期 Workflow 版本 system prompt 写死工具调用顺序,品类行情查询也走全链路。改 Agent 后又太自由,简单问题也调一堆工具。
- 修法:引入 planner 做意图驱动——planner 解析意图输出 tasks,模型在 tasks 范围内组合工具。约束下的自主决策。
- 教训:Agent 核心价值不是”自由”,是”按意图灵活组合能力”。
坑 2:Prompt 级规则对弱模型约等于”祈祷”#
- 现象:强模型很听话,换 Flash 系列子 Agent 后无视终止规则——不停重搜或自行调
shopping_summary。 - 修法:行为约束从 prompt 级提升到代码级机制——四层 fork 安全、阶段状态机、检索预算。
- 教训:凡是”模型不遵守就会出事”的规则,必须有代码级兜底。机制打底,prompt 辅助。
坑 3:摘工具会破坏 prompt cache 的前缀匹配#
- 现象:阶段状态机最初动态从工具表里移除被禁工具,prompt cache 命中率断崖下跌。
- 修法:工具表恒定 + 执行层拦截。模型看到完整 13 个工具,但越权调用被哨兵拒绝返回 sentinel。
- 教训:任何会改变 system prompt 内容的动态行为都要评估对 prompt cache 的影响。
坑 4:ContextVar 的 set 是单向的——子 set 了不会回写给父#
- 现象:检索预算计数用 ContextVar,子 Agent fork 出去后各持快照副本,父看不到子的消耗。
- 修法:预算计数改 module-level dict(以
session_dir为 key),session_dir通过 ContextVar 只读传播。 - 教训:隔离用 ContextVar,共享用 module dict + namespace key。
坑 5:延迟减法——96% 是 LLM 解码不是后端代码#
- 现象:端到端 83.2s,直觉以为向量检索或网络慢,trace 一看 ~96% 是 LLM 解码(reasoning/thinking token)。
- 修法:四刀——① 子 Agent 关 reasoning(50s→5s)② planner 确定性预置省一轮 ③ 收尾文案不逐件重写 ④ 追问轮 picker 不抄 id。结果:83.2s→39.7s,9 轮→5 轮。
- 教训:优化前先量——瓶颈在 LLM 解码,后端代码优化收益约等于零。
坑 6:KB 两段式检索——一次性语义搜索会混品类#
- 现象:
category_insight一次性语义搜索”旅行包”,混进”旅行杯""旅行枕”等无关品类的卡片。 - 修法:拆成两段——resolve_category 先做品类投票确定目标品类,fetch_cards 按 resolved 品类精确取卡。
- 教训:RAG 检索要先确定”在哪个域里找”再找,不能一步到位。
三、验收与量化#
端到端 Rubric 评测#
三级评分:P0 业务红线(一票否决)/ P1 执行规范(扣分)/ P2 质量打分(1-5)。聚合公式:total = (p2_avg / 5) × 100 - penalty × len(p1_violations),P0 任一 fail 则 total=0。
种子 query 集覆盖多场景桶(多约束精挑 / 红线-预算 / 跨平台比价 / 品类洞察 / 闲聊兜底 / 跨语言召回 / 到手价 / 记忆注入 / 澄清 / 外部事实),动态生成评分细则 + judge 两刀打分。
关键洞察:先校准 judge 再改 Agent——首轮基线中多条 P0 fail 是 judge 假阳性(轨迹泄露当信息安全 fail、凭空造预算红线),校准后大幅涨分,一行 Agent 代码不动。
延迟治理#
瓶颈:~96% 是 LLM 解码时间。治理手段:
- 子 Agent 关 reasoning:单子 Agent ~50s→~5s
- planner 确定性预置:省掉”模型花一轮只为说出 planner 这个词”
- 收尾文案不逐件重写:确定性理由补强 + LLM 只重写前 3 件
- 追问轮 picker 不抄 id + reuse 轮不开 reasoning
- 结果:端到端 83.2s→39.7s,主 loop 9→5 轮
各模块验证方式#
| 模块 | 验证方式 |
|---|---|
| Rubric 评测 | 三级评分 + judge 校准 + 迭代对照 |
| 召回层 | Recall@K / MRR / NDCG(Recall@10=0.983) |
| fork 安全 | 回归测试(递归拦/超时/截断/循环检测) |
| Harness | 回归测试 + 端到端 Rubric 间接反映 |
| 安全护栏 | 白名单/脱敏/过滤测试 + P0 泄露消除 |
| 偏好记忆 | 跨会话持久化 + 注入验证 + curator 提取 |
四、面试问答#
Q1: Agent 主循环怎么设计的?#
Think→Act→Observe→Reflect 四阶段循环,基于 LangGraph create_agent。和 ReAct 区别:不写死步数,模型自判终止;四层机制兜底弱模型。主循环 30 次迭代 / 300 秒超时,子循环 6 次 / 90 秒。recursion_limit = MAX_ITERATIONS * 2 + 1(LangGraph 一轮 = 两个 super-step + 最终响应 1)。
Q2: 为什么做成 Agent 不做 Workflow?#
Workflow 一刀切全链路,品类行情也走检索+比价。Agent Think 阶段按意图选工具,但纯自由又太松。最终方案:意图驱动 planner 输出 tasks,模型在 tasks 范围内组合——约束下的自主决策。
Q3: 同质 fork “同质”是什么意思?#
子 Agent 和主共享同一份 FULL_TOOL_SET(13 工具)、同 prompt、同中间件栈。不做专用化裁剪:简化维护 + 教学架构展示可组合性。子也能调 shopping_summary 但被 depth_gate 执行层拦截。
Q4: 重新设计会做哪些不同?#
① 候选缓存从 module-level dict 改 ContextVar 防多会话串扰 ② 工具可见性裁剪需量化 cache 收益 vs 浪费 token ③ 事件回放需完整 event sourcing 支持历史审计
Q5: 最大技术风险?#
LLM 服务单点依赖(只配一个 provider)。进程内状态(排队/熔断/去重在内存),多副本需迁 Redis。
Q6: 150 万商品、零 GPU、离线评测——生产能用吗?#
不能直接用,但每个缺失能力有升级路径。数据需真实平台 OAuth + 反爬 + 库存一致性。零 GPU 的 embedding/reranker 走 API 需本地部署。离线评测需加埋点 + 线上指标 + 灰度。项目价值在于 Agent 架构设计、治理框架、质量保障体系这些可复用的工程能力。
Q7: 支持 1000 QPS 怎么改?#
① 多 Uvicorn + LB,ConnectionManager 改 Redis Pub/Sub ② LLM 网关排队 + failover ③ Qdrant 分布式 + read replica ④ Agent 执行改消息队列。当前瓶颈在 LLM 响应时间,默认 slot=10 约 0.33 QPS。
五、前沿概念#
5.1 Agent 范式对比#
| 范式 | 适用场景 | 不适用 |
|---|---|---|
| Workflow/DAG | 步骤固定、需确定性 | 意图多样、步数不确定 |
| ReAct | 单工具简单场景 | 多工具组合、需收敛判断 |
| AgentLoop | 意图多样、灵活工具组合 | 确定性要求极高(金融交易) |
| Plan-and-Execute | 可提前规划、步骤弱依赖 | 动态场景 |
| Multi-Agent 同质 fork | 子任务同质、需并行隔离 | 子任务差异大 |
5.2 机制优于 Prompt#
代码级机制的优势是确定性,prompt 的优势是灵活性。两者互补:prompt 覆盖正常路径(80%),机制兜底异常路径(20%)。
| 行为约束 | prompt 层 | 机制层 |
|---|---|---|
| 终止 | ”信息充分时调终结工具” | 迭代上限+超时+循环检测 |
| 工具顺序 | ”先 planner 再 item_search” | 阶段状态机+哨兵 |
| 偏好遵守 | ”不推荐黑名单属性” | item_picker 硬过滤 |
| 递归控制 | ”子不要再 fork” | 深度闸 MAX_FORK_DEPTH=1 |
| 成本控制 | ”不要过度检索” | 检索预算+token 预算闸 |
5.3 Harness Hook Pipeline#
类似 Web 框架中间件或 Git pre-commit hook。核心好处是开闭原则——对扩展开放(加文件+装饰器),对修改关闭(不碰 main_agent.py)。与 Web middleware 的区别:粒度更细(6 个 Hook 点 vs request/response 两个)、context 更丰富(能看到工具参数/返回值/历史轨迹/阶段状态)。
5.4 零 GPU 迭代闭环#
Rubric 评测 → 定位 bad case → 改 prompt/工具/Harness 规则 → 再评测对照。不改权重只改上下文。天花板取决于模型的上下文学习能力,实测比预期高。真正需要 GPU 的场景是模型基础能力不够——弱模型连 schema 都解析不准就不是上下文能修的。
六、诚实边界#
| 模块 | 做了 | 没做 | 什么时候该做 |
|---|---|---|---|
| 数据 | 138 万条 Kaggle 公开数据集 | 无实时爬虫/OAuth/库存一致性 | 接真实平台时 |
| 模型 | 全走 API | 不做 SFT/RL/微调(无 GPU) | 拿到 GPU + 标注数据时 |
| 评测 | 离线 Rubric + 种子 query 集 | 无线上 A/B / 埋点 | 上线真实用户时 |
| 存储 | 进程内状态 | 多副本需迁 Redis | 需水平扩展时 |
| 隔离 | ContextVar + 独立实例 | 无 checkpointer | 需可恢复任务时 |
| 鉴权 | JWT 验证 + Alembic 迁移 | 未做 thread→owner 资源级鉴权 | 多用户共实例时 |
| 前端 | React + Vite 全功能 | 非专业前端、未移动端适配 | 面向终端用户交付时 |
| 降级 | 三层降级链 | 本地 fallback 语义质量低 | 永远——保底不是替代 |