面试知识库

1. 项目全景与架构演进#

一句话结论:ShoppingX 是跨境购物 Agent —— 一个模型、一个循环、19 个业务工具,跑在 API + worker 两个进程里,真相全在 MySQL / Redis。

速背卡#

#hint展开一句
1一环:单模型单环无子 Agent并行靠同轮多发 item_search,不靠派发(2026-09-16 按 440/60/0 删掉)
2两进程:API 入队 / worker 跑 loopapp/api/server.py 只准入与入队,app/worker.py 领任务调 run_agent
3三道跨进程闸run_holds 用户级并发 / QUEUE_MAX_DEPTH 队列深度 / WORKER_CONCURRENCY
4三份真相搬进库配额、会话归属、同 thread 唯一——MySQL + Redis,进程内不留
5四阶段后端优化1 多副本 / 2 网关 / 3 写边界 / 4 依赖与超时(2026-09-19~21)
6收尾靠 6 个终结工具TERMINAL_TOOLS,外加 max_iters=30 与整轮 300s 硬停
79-16 以来 63 次提交git log --since=2026-09-16 --no-merges,分成 A/C/D/M/S + 四阶段 + 减法

30 秒版#

跨境购物 Agent:用户一句「旅行三件套、预算 300、不要塑料」,它自己拆意图、跨平台检索、比价、算关税运费,给一份带理由的清单,并把偏好记成长期事实。架构是单模型单环——全仓只有一个 Agent,拿全部 19 个工具,要并行就在同一轮发多个工具调用。部署是 API 与 worker 两个进程 + Redis Stream 队列 + MySQL,可以横着加副本。

3 分钟版#

  1. 业务:对话式购物,链路是 planner 拆意图 → 检索 → 比价 / 到手价 → 精挑 → 带理由收尾;不做支付、物流、真实平台 OAuth。
  2. 主循环:AgentScope 2.0 的 ReAct Agent,Think→Act→Observe→Reflect,模型调到终结工具才停(app/agent/constants.py:27,6 个)。
  3. 并行:同一轮发多个 item_search(platform=… / slot=…),工具标 is_concurrency_safe=True,框架 gather 执行。
  4. 控制面:25 个 hook 挂在 6 个 hook 点上(app/harness/hooks/),管安全、终止、预算、顺序、循环检测、上下文整形。
  5. 写边界:靠机制不靠提示词——is_read_only 标记 + PermissionEngine 逐个放行 + 确认卡只走 HTTP 决议 + 按 run_id 幂等。
  6. 部署:API 进程做准入(配额 / 归属 / 幂等 / 预扣)后入队,worker 进程消费并跑 AgentLoop,事件经 Redis 背板回推 WebSocket。
  7. 演进:LangChain→AgentScope、Faiss→Qdrant、同质 fork→Supervisor-Workers→单环、SQLite 单机→MySQL 多副本,每步都有推翻它的数据。

它解决什么问题#

① 并行检索该不该用子 Agent

  • 问题:跨平台、多槽位要同时搜几路。
  • 坏法:给主 Agent 一个 task_dispatch 派子 Agent。坏在看不见——线上 TradeAgent 440 会话 0 次派发、SearchAgent 60 次派发全是「派出去只调一次 item_search 就回来」的单跳壳,隔离与深链两条理由都没兑现,但功能照常出结果,不看会话数据根本发现不了。
  • 修法:2026-09-16 整条派发删除,并行改同轮 batch(A 系列)。
  • 代价:丢了「批次视角」,模型少发一路没人拦;子 Agent 那套经验只剩演进故事。

② 单机跑得动,多副本就各算各的

  • 问题:配额、会话归属、「同 thread 只跑一个」原先都在进程内存 / SQLite。
  • 坏法:加副本后每台各有一份账,不报错,要等用户投诉才看得出来(app/deployment.py docstring)。
  • 修法:阶段 1 把三份真相搬进 MySQL + Redis(run_holds 预授权表、threads 条件更新、dedup 窗口进 Redis),起服前用 assert_deployment_deps() 拦掉 SQLite 与 Redis 不可达。
  • 代价:本地起后端必须先拉起 MySQL + Redis,不能裸跑。

③ 一个模型出口,换供应商等于改全局

  • 问题:全仓只有 OPENAI_* 一个出口,一条链路上不可能同时用两家。
  • 坏法:跨家 fallback 根本不成立,供应商抖一下整条链路跟着挂。
  • 修法:阶段 2 把出口写进模型名(LLM_MAIN=dashscope/qwen3.8-flash),寻址交 LiteLLM Router,断路器与令牌桶自研(app/agent/llm.py、gateway.py)。
  • 代价:多一层垫片;LLM_PROVIDER_ROUTER=0 留着回滚。

④ 偏好越存越像一张脏表

  • 问题:PreferenceEntry 用 domain/slug 派生 dedup_key,判重规则越加越多。
  • 坏法:记忆 bug 不崩,只把推荐做反——看不出来。
  • 修法:M 系列换成 memory_facts 表,key 即身份、同 key 覆盖,validate_fact 是唯一写入口(三条写路径都过它)。
  • 代价:旧 preferences 表留了一版才 drop(D2 580e857),迁移期两套并存。

⑤ 状态散在十几个模块级 dict 里

  • 问题:一次 run 的收货国、槽表、候选登记表、预算计数各存各的,清理要写 9 个 reset。
  • 坏法:漏清一个,下一轮 planner 还没跑就按上轮结论走。
  • 修法:减法 D4(a6d7f49)收成 app/api/run_state.py 一张表,14 个 dict → 1,9 个 reset → 1。
  • 代价:多一层间接访问;_diagnostics._CHANNEL 因键语义不同刻意留在外面。

机制怎么跑(一次请求的分层路径)#

每层一行,括号里是它住在哪个文件。

两条规矩记住:AgentLoop 不在 API 进程里跑(1-7 0978dc1 删掉了直跑分支);save_session_state 只在成功收尾时写,取消 / 超时保留上一轮那份。

演进时间线#

2026-09-16 之前(旧版手册核对过,git 最早一条是 612a744 / 2026-07-17,此前历史被压成一个提交):

时间改了什么为什么
早期Faiss 本地索引 → Qdrant dense + payload filter要多维过滤(platform / price_usd / rating)与全量 138 万点全部存磁盘
2026-09-05/06LangChain/LangGraph → AgentScope 2.0,删 AGENT_RUNTIME 开关运行时统一;难点是静默失效,摘依赖时查出 4 个(ae4073e)
2026-09-06同质 fork → Supervisor-Workers(读写切分)同框架对照 6 条×2 遍:墙钟 52.6s / 74.8s、输入 token 7.06 万 / 12.10 万(样本小,中位数只差 15%)
2026-09-14跨轮产物 5 份 → session.json 一份,压缩交框架多份真相对不齐;删 history 回放腿
2026-09-15延迟 round3 五刀:开局预取 KB、检索后自动比价精挑q_backpack 28.3→17.5s、模型调用 9→4 次、输入 79k→22.5k

2026-09-16 起共 63 次非合并提交,按组看:

组提交改了什么为什么
A 单环收敛1396ed1 cccdf1a d6be5de f8b3178 ec46140 等删整条派发、定点调查、WORKER_MODE、全树口径440 会话 0 派发、60 次派发全是单跳壳;定点调查 481 会话真实用户 0 次
C research 与对比95103a1 91258f3 ae56aa6 7d05fb2 2f2b304 1ca54fa ddaf043research(targets, aspects) 有界函数 + 独立配额;present_comparison 终结工具 + 对比栏直连 REST;修 chat_fallback 吞答案(61 → 1404 字节)网页正文不能进主环;「对比已给过的几件」是按钮行为,不必走 loop
D 压缩与记忆工具3f5a292 fc855be 1fa79ca 484ad68 42311aa工具返回式压缩(最旧的换占位,先于框架摘要)、ask_user 加 closes_turn、recall_memories 只读工具、缓存命中率门禁框架摘要是二手上下文,能不摘就不摘;命中率要有回归门
M 记忆事实库e46d1ed 8b64026 4148e20 c300282memory_facts 表 + validate_fact 单门 + tier-one 注入 + 回合后抽取 + 保留期 + 偏好页事实表单key 即身份,判重规则不再自己长;fact_key 列名避开 MySQL 保留字
S skill53a48ea 8b851f4 3943ecf 451b6d5 c9220593 个新 skill + system prompt 瘦身;删 skill 预注入改模型自觉 + 同轮发出;present_guide 终结工具;触发验收集(正例 11/13 → 13/13)预注入让同一份正文在 3 轮会话里躺 6 次;机制只管事实接地,打法交模型
阶段 1 多副本9ddd290 b79efb7 c15ac5b e9022bb a2b4ed8 f9efe2b 0978dc1 83378acrun_holds / threads 条件更新 / interrupted 收尾 / ARTIFACT_ROOT / 四场景验收 / SQLite→MySQL 搬数 / 删单机分支 / 参数覆盖与库对账多副本是唯一形态,进程内不留真相
阶段 2 网关e1cc9e8 f9fb447 5ba2df6 770163f 8ea5504 92a2a7d df21fae c869135LiteLLM spike 四判据 → Router 寻址;断路器只对「对面挂了」计数;fallback 能力门;双令牌桶(RPM+TPM 同生共死、预扣结算)每进程限流在多副本下等于 N 倍速率,429 由此而来
阶段 3 写边界bfa99c3确认卡按 run_id 幂等,订单号撞了重分不覆盖整轮重跑不能落两张卡
阶段 4 依赖与超时307d12d 383ba8b d40b205 191b817 30e1a41入队等待上限(作废并原路退预扣)、deadline 下传、检索依赖报「别重试」、Qdrant/OpenSearch 各挂断路器、request_id 随队列过去两个进程的日志要接成一条线;挂了别每次等满超时
SLOc55a1ffrun 成功率 + 首事件延迟两条,口径先定死分母只算 success+failed,cancelled 与 dependency_rejected 排除
减法91616db(A) 83f6678(C1) 408c99d(C4) e29fcba597656c(D1D5)删跑不起来的示例与 spike;server.py 1779→1106 行拆出 3 个 router;删只剩一个值的 role 参数;检索计数只留一条路;item_picker 420 行拆七阶段;run 状态收成一张表;env 键 253→222单环之后的残余不删,下一个人会照着它建模
Skill 页0d50ff4Skill 页列出系统内置 skill + 只读详情用户看得见有哪些方案可选

数字与证据#

数字指什么来源状态
19_BUSINESS_TOOLS 元素个数app/agent/tool_registry.py✓ 自己数过
6TERMINAL_TOOLS 元素个数app/agent/constants.py:27✓
25 / 6harness hook 个数 / hook 点个数grep -c '@harness_hook' app/harness/hooks/*.py✓
30 / 300sMAIN_MAX_ITERS / MAIN_AGENT_TIMEOUT_SEC 代码默认app/agent/limits.py✓(.env 把超时设成 600,旧版核对过)
4 / 330sWORKER_CONCURRENCY / WORKER_GRACE_SECONDSapp/worker.py:58,62✓
190 / 36,098app/ 下 Python 文件数 / 总行数find app -name '*.py' | xargs cat | wc -l✓(2026-09-22)
103tests/ 下测试文件数(非用例数)ls tests/*.py | wc -l✓
17Alembic 迁移条数ls migrations/versions✓
7系统内置 skill 目录数ls skills/✓
632026-09-16 起非合并提交数git log --since=2026-09-16 --no-merges✓
1779 → 1106server.py 拆 router 前后行数83f6678 提交标题✓ 提交正文;现为 1110 行(其后又有改动)
253 → 222app 读的 env 键数597656c 提交标题仅口径(未自行复算)
14 → 1 / 9 → 1run 状态 dict 数 / reset 函数数a6d7f49 提交正文仅口径
440 / 60 / 0删派发依据:TradeAgent 会话数 / SearchAgent 派发数(全单跳)/ 真实用户定点调查次数旧版手册核对过,A 系列提交正文旧版核对过
52.6s / 74.8ssplit / clone 平均墙钟(已被单环推翻,只作演进证据)data/eval/worker_mode_*.json旧版核对过;样本小
11/13 → 13/13S4 skill 正例触发率c922059 提交标题仅口径

追问 10 题#

Q1. 一句话说清这个系统的形态? 单模型单环 Agent + 两进程部署:API 只准入与入队,worker 跑 AgentLoop。证据:app/api/server.py:create_task、app/worker.py。

Q2. ⚠ 为什么没有子 Agent? 做过两代(同质 fork、Supervisor-Workers),按线上数据删的:440 会话 0 派发、60 次派发全是单跳壳。并行改同轮 batch。详见第 3 章。

Q3. 主循环怎么停? 三道:终结工具六选一(constants.py:27)、max_iters=30、整轮 asyncio.timeout 300s。第 2 章展开。

Q4. ⚠ 队列是可选的吗? 不是,恒开。1-7 0978dc1 删掉了 QUEUE_ENABLED / CONTROL_ENABLED / BACKPLANE_ENABLED 三个开关和 API 进程直跑分支。

Q5. 为什么 DATABASE_URL 必须是 MySQL? SQLite 下每台副本各算各的配额 / 归属 / 并发,不报错。app/deployment.py:assert_deployment_deps 起服时 fail-fast。

Q6. 多轮会话靠什么恢复? 只有一条腿:ARTIFACT_ROOT/<thread_id>/session.json 存 AgentState,坏了空开局;会话级 P_t 在 state.middle_context。第 5 章。

Q7. 限流和熔断为什么不全用现成的? 寻址用 LiteLLM Router(换出口不重写 _call_api),桶与断路器自研——供应商配额是 RPM/TPM 两维且要跨副本共享一本账。第 9 章。

Q8. ⚠ SLO 成功率的分母是什么? 只算 success + failed;cancelled(用户按停止)与 dependency_rejected(Qdrant 挂了如实告知)都排除(c55a1ff)。

Q9. 写操作怎么保证不重复? run_id 是幂等锚:credit 预扣按它结算,确认卡按它 request_key 去重,整轮重跑只落一张卡(bfa99c3、app/trade/confirmations.py)。第 3 章。

Q10. 压力题:这套架构最大的弱点是什么? 同轮 batch 没有「批次视角」——模型少发一路没人拦,旧的 _platform_guard 只能拦「派了没启用的平台」,删派发时一并去掉。现在靠提示词与评测兜,属于已知缺口。

坑与易混点#

  1. 文档落后于代码:docs/ARCHITECTURE.md 还写 BUSINESS_TOOLS (15)、「十二大工具」、main_agent.run_agent(),实际是 19 个工具、入口在 app/agent/orchestrator.py。
  2. CLAUDE.md 写「十八大工具」,代码是 19 个(S3 加了 present_guide);该文件当前未进 git(git show HEAD:CLAUDE.md 报不存在)。
  3. run_state.py 在 app/api/ 不在 app/harness/,虽然它服务的是 run 级状态。
  4. 超时数字有两份:代码默认 300s,.env 设 600s,别只报一个。
  5. 子 Agent 相关材料还在仓库里(docs/milestones/M2、M25、scripts/archive/),是被推翻的中间态,别照着迁回来。

本章和别章的接口#

  • 主循环与终止安全在第 2 章;单环收敛细节与写边界在第 3 章。
  • 检索管道在第 4 章,记忆事实库在第 5 章,控制面 hook 在第 6 章。
  • 网关 / 断路器 / 令牌桶在第 9 章,多副本与队列在第 10 章,Skill 体系在第 14 章。