00 · 面试总览#
两个项目共 11 条 bullet,一篇对应一条。本页是开口前的速查:先看架构图把全局串起来,再看每条的 30 秒口述版。
开场 1 分钟串讲#
ShoppingX 是我独立做的跨境购物 Agent,覆盖多个平台约 150 万商品。用户说一句需求,比如「预算 300 的旅行三件套、不要塑料」,系统会拆出约束、跨平台并发检索、比价算到手价、按偏好精挑,最后给一份带理由的清单,还能加购。
项目分两块讲:
- Agent 核心:单模型单环的 Agent 主循环,外面包一层 Harness 控制面约束执行轨迹;上下文分层吃满前缀缓存;延迟从 28.3s 压到 12.5s;用 Rubric 动态评测做迭代依据;检索侧微调 embedding 和 reranker,Query 解析模型做了后训练。
- 服务化后端:长任务走 Redis Stream 队列、租约接管,多副本无状态部署;OpenTelemetry 全链路追踪定位过事件循环阻塞;模型网关令牌桶限流加断路器;WebSocket 事件多实例转发、断线补发;Credits 预扣结算保证计费准确。
整体架构图#
flowchart LR
U["用户浏览器"] -->|"HTTP 提交任务"| API["API 实例 FastAPI"]
U <-->|"WebSocket AG-UI 事件"| API
API -->|"准入 预扣 Credits"| DB[("MySQL")]
API -->|"XADD 任务"| RS[("Redis Stream 任务队列")]
RS -->|"消费者组 租约"| W["Worker 实例"]
subgraph W_IN["Worker 内:单模型单环 AgentLoop"]
H["Harness 6 个 Hook 切点"] --- L["Think Act Observe Reflect"]
L --> T["工具:planner item_search price_compare item_picker shopping_summary 等"]
end
W --- W_IN
T -->|"向量召回 精排"| Q[("Qdrant 约150万商品")]
L -->|"令牌桶 断路器"| GW["模型网关"] --> LLM["LLM 供应商 主备模型"]
W -->|"事件写流 + Pub/Sub"| EV[("Redis 事件流")] --> API
W -->|"结算 用量"| DB
W -.->|"OpenTelemetry"| OBS["Langfuse / Trace"]
OBS -.-> EVAL["Rubric 评测 → bad case → 改 Prompt Hook Skill"]
11 条 bullet 速查#
| # | 文档 | 一句话 | 关键数字 |
|---|---|---|---|
| 1 | 01-Agent主循环与Harness | 同轮并发 + 6 切点 5 类规则 + 写操作三级拦截 | 修复换品类死循环 |
| 2 | 02-上下文与记忆 | 稳定性分层 + Skill 按意图加载 + 三类长期记忆 | 前缀缓存命中 93% |
| 3 | 03-延迟优化 | 框架自动执行组合步骤 + ID 引用压上下文 | 9→4 次调用、79k→22.5k、28.3s→12.5s |
| 4 | 04-Rubric动态评测 | 每条 query 固定三档细则、修 judge 误判 | 版本间可比 |
| 5 | 05-检索模型微调 | embedding 对比学习 + reranker listwise | R@100 +12.6%、NDCG +14.4%、Rubric 50.4→58.0 |
| 6 | 06-Query解析模型后训练 | SFT 冷启动 + 三轮 best-of-8 拒绝采样 | 合法率 61%→100%、P@20 0.399→0.467 |
| 7 | 07-任务调度 | Stream 消费者组 + lease 续租 + 版本号 + 按步续跑 | kill -9 无丢失 |
| 8 | 08-可观测性与性能排查 | 跨 Stream 追踪、同步回查挪线程池、int8 常驻内存 | 860ms→5ms、1.3s→49ms |
| 9 | 09-限流熔断 | Lua 令牌桶按 RPM/TPM + 5xx/首 token 超时熔断 | 100 并发成功率 100%、首事件 P95 0.32s |
| 10 | 10-实时推送 | Pub/Sub 转发 + Stream 补发 + 前端去重 | 不丢不重 |
| 11 | 11-计费一致性 | 预扣 + 按量结算 + 超时对账 | 重复提交只扣一次 |
各条 30 秒口述版#
01 · Agent 主循环与 Harness#
ShoppingX 是单模型单环的购物 Agent:一个主模型持有全部业务工具,按「想 → 调工具 → 看结果 → 判断够不够」循环,直到自己调收尾工具。跨平台检索不派子 Agent,而是模型在同一轮里发多条检索调用,每个平台或每个套装槽位一条,框架把标了并发安全的调用合成一批并发执行。控制面我基于 AgentScope 的模型中间件和工具中间件封装了 6 个 Hook 切点,上面注册终止、安全、预算、重复、漂移 5 类规则;写操作要过只读标记、权限引擎、用户确认卡三级拦截,模型本身没有真正下单的能力。印象最深的是一个死循环:用户在长会话里从背包换到颈枕,planner 输出校验失败,收尾闸又只认成功过的 planner,模型反复重搜、反复撞闸,一轮 58 次模型调用、180 秒超时;修完后同一轮重放 4 次调用、25 秒正常收尾。
上下文与记忆#
ShoppingX 是单模型单环的 Agent,一轮购物任务主环要调 3~5 次模型,每次都把 system prompt、十几个工具定义和整段历史重新发一遍,所以输入 token 的大头是「重复内容」。我做的第一件事是把上下文按稳定性分层:角色、流程、工具定义、Skill 目录这些全天不变的放最前面,会话历史只追加不改写,每轮都会变的平台设置、行为历史、长期记忆一律放在最后,而且注入的东西要跟着会话状态存下来,不能只出现在当轮。这样 20 轮的长会话里,前缀缓存命中率做到 93%。第二件是把套装规划、到手价、订单售后这些业务流程拆成 7 个 Skill,system prompt 里只留一行描述,模型按意图判断要用时再读正文。第三件是长期记忆:按「硬约束 / 偏好 / 身份背景」三类存,模型在对话里可以显式保存,回合结束后还有一个小模型读原话补抽,两路都过同一道 PII 过滤门。
延迟优化:9 次模型调用压到 4 次#
一个普通的购物任务原来端到端要 28 秒。我按调用链拆时间,发现工具合计只占 2 秒多,其余全是串行的模型调用;其中「比价」「精挑」两轮模型没做任何决策,只是把上一步结果搬进下一步参数。于是我做了两件事:一是检索返回后由框架自动跑比价和精挑,结果以提示注入,模型下一步直接收尾;二是下游工具只收商品 ID,完整信息放在会话级登记表里按 ID 取,模型不用读长 JSON,也不用在参数里重写商品。单任务平均模型调用 9→4 次,累计输入 token 79k→22.5k,端到端 28.3 秒→12.5 秒。
Rubric 动态评测#
购物 Agent 的好坏没法用一个固定指标衡量:「送女朋友的礼物」和「批量采购螺丝」的红线完全不一样。所以我让 judge 模型针对每条线上 query 先生成一份专属评分标准,分三档:P0 业务红线一票否决,P1 执行规范按条扣分,P2 质量维度打 1~5 分。标准生成一次就冻结,按 query、约束和生成 prompt 的哈希存起来,之后每个版本都用同一把尺子打分,版本之间的分差才能归到 Agent 身上。打分结果作为 score 挂回那次运行的 Langfuse trace,低分直接能点进去看当时的轨迹。我花力气最多的是校准 judge:它会把内部执行日志当成信息泄露、把软偏好升级成红线、偶尔回一张空表导致「越坏越绿」,这些修掉以后,再按「评分→定位 bad case→改 Prompt / Hook / Skill→用同一把尺子重评」这个循环迭代。
检索模型微调#
ShoppingX 的检索是两段:BGE-M3 向量召回,再用 BGE-Reranker 精排。开箱模型在我们的商品库上有两个问题:一是召回漏掉真正相关的商品,二是正例进了候选池却排不到前面。我用亚马逊公开的 ESCI 人工标注数据,按 ASIN 和自己的库对上,拿到约 12.7 万个商品、5.4 万条有正例的 query。embedding 侧做对比学习:InfoNCE 加 in-batch 负例和挖出来的难负例,用 cross-encoder 把「假负例」筛掉;最有效的一刀是把每条 query 的全部正例都展开用上。reranker 侧把 ESCI 的 E/S/C/I 四档变成 3/2/1/0 分级标签,做 listwise 交叉熵,再加一路 pointwise BCE 保分数校准。在 138 万全库、1.1 万条标注 query 上评测,R@100 +12.6%,NDCG@100 +14.4%;两个模型换上线后,33 条种子集的端到端 Rubric 均分从 50.4 到 58.0。
Query 解析模型后训练#
ShoppingX 每一轮对话开头,先由一个 Query 解析模型(项目里叫 planner)把用户原话拆成结构化字段:品类、品类域、预算、检索词、排除词,后面的检索全靠这几个字段。直接拿 Qwen3-8B 加提示词做,有两个毛病:一是结构化输出不稳,开发集上只有 61% 能解析通过;二是拆出来的东西“看着对、搜不到”,比如把中文整句塞进检索词,打英文商品库召回很差。我先用 2154 条合成的逐轮样本做 SFT 冷启动,把格式合法率拉到 100%;再做三轮 best-of-8 拒绝采样:每条样本采 8 个答案,把检索词真打进向量库算奖励,挑最好的一条回炉训练。最后检索 P@20 从 0.399 到 0.467,综合 reward 从 0.711 到 0.796。
任务调度:Redis Stream 队列 + 租约接管 + 按步续跑#
ShoppingX 一轮购物任务要跑 4 次左右模型调用加一串工具,十几秒到几分钟,所以我把「收请求」和「跑 Agent」拆成 API 和 worker 两种进程,中间用 Redis Stream 消费者组做 at-least-once 队列。worker 领到消息就占一个 30 秒的租约键,每 10 秒用 Lua「值是自己才续期」,进程死了租约自然过期,空闲的 worker 抢到租约再 XCLAIM 接管。接管时发一个新的任务版本号,所有写入都带版本号做条件更新,被判死但其实还活着的旧 worker 写不进去。执行状态按步存进 MySQL,接管方从最后一个检查点接着跑,不重调已经算过的模型;扣费按 run_id 只结算一次,加购按「run_id + 动作 + 载荷指纹」唯一键只出一张确认卡。在 2 API + 2 worker 的多副本环境里反复 kill -9 任一 worker,运行中的任务无丢失。
08 · 可观测性与性能排查#
ShoppingX 的任务是 API 收请求、写进 Redis Stream,再由 worker 消费执行,一个 worker 进程用一个事件循环同时跑几十个会话。早期 trace 只覆盖 worker 里的那一段,排队和执行连不起来。我用 OpenTelemetry 把 W3C traceparent 写进 Stream 消息,worker 取出后接着同一条 trace 往下挂,API、排队、Agent 主循环、每次模型和工具调用就在一棵树上了。接上之后看到一个现象:同一个 worker 里互不相关的几个会话,span 在同一时间窗口一起变长。多个会话同时变慢,说明它们共用的东西被占住了,这个东西就是事件循环。最后查到是按商品 ID 回查 Qdrant 的同步调用把循环卡住了,改成丢线程池执行后,其他会话的停顿 P99 从 860ms 降到 5ms。回查本身为什么慢?高峰期它和向量检索抢磁盘,我给向量做了 int8 标量量化、常驻内存,回查 P99 从 1.3s 降到 49ms。
09 · 限流熔断#
ShoppingX 一个任务要调 4~9 次模型,多副本一起跑时,每个进程各自限速没用,供应商看到的是所有副本加起来的速率,429 就是这么来的。我在模型网关里做了两件事:一是 Redis Lua 令牌桶,按「供应商 + 模型」开 RPM、TPM 两个桶,两个桶要么一起扣、要么都不扣,TPM 先按估算预扣、响应回来按真实 usage 找平;二是断路器,只把 5xx、连接失败和首 token 超过 15 秒记成故障,429 和慢流不算,连续 5 次故障就熔断,请求直接切到备用模型。入口那层再用一条 Lua 做原子准入,队列满了直接回 429 + Retry-After。压测 100 并发,准入请求 100% 成功,首事件 P95 0.32 秒。
实时推送:跨实例事件转发 + 断线补发 + 前端去重#
ShoppingX 一轮任务要跑十几到几十秒,中间的思考、工具调用、商品卡都靠 WebSocket 实时推给前端。服务化以后任务在独立 worker 里跑,浏览器的 WebSocket 却连在某个 API 实例上,两边不在一个进程。我用两条通道分工:实时这条走 Redis Pub/Sub,worker 把事件广播出去,每个 API 实例查自己的连接表,谁持有这条会话的连接谁推;可靠这条走 Redis Stream,每个会话一条流,事件先写流、拿到 Redis 生成的单调 id 再推送。断线重连时前端带上收到的最大 last_event_id,服务端先登记新连接、再补发它之后的事件。补发和直播可能重叠,前端按事件 ID 去重,这样既不丢也不重。
计费一致性:预扣 + 按量结算 + 超时对账补偿#
ShoppingX 按任务的真实模型成本扣 Credits。最早是跑完再记账,有两个洞:同一个人并发发 20 条,每条进门都读到满余额,全放行后一起记账就透支了;worker 被 kill -9 时收尾不执行,钱花了账没记。我改成三段:进门锁用户行,按档位预扣并数在飞任务数,后到的请求看到的是扣过的余额;跑完按实际成本多退少补,结算和记账同一事务、以 run_id 幂等;每次模型调用把用量累加进 Redis,崩溃留下的过期预扣由对账任务按用量补扣。重复提交分层挡住,队列重投整轮重跑也只结算一次。