1. 项目全景与架构演进#
一句话结论:ShoppingX 是跨境购物 Agent —— 一个模型、一个循环、19 个业务工具,跑在 API + worker 两个进程里,真相全在 MySQL / Redis。
速背卡#
| # | hint | 展开一句 |
|---|---|---|
| 1 | 一环:单模型单环无子 Agent | 并行靠同轮多发 item_search,不靠派发(2026-09-16 按 440/60/0 删掉) |
| 2 | 两进程:API 入队 / worker 跑 loop | app/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 硬停 |
| 7 | 9-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 分钟版#
- 业务:对话式购物,链路是 planner 拆意图 → 检索 → 比价 / 到手价 → 精挑 → 带理由收尾;不做支付、物流、真实平台 OAuth。
- 主循环:AgentScope 2.0 的 ReAct Agent,Think→Act→Observe→Reflect,模型调到终结工具才停(
app/agent/constants.py:27,6 个)。 - 并行:同一轮发多个
item_search(platform=… / slot=…),工具标is_concurrency_safe=True,框架gather执行。 - 控制面:25 个 hook 挂在 6 个 hook 点上(
app/harness/hooks/),管安全、终止、预算、顺序、循环检测、上下文整形。 - 写边界:靠机制不靠提示词——
is_read_only标记 +PermissionEngine逐个放行 + 确认卡只走 HTTP 决议 + 按run_id幂等。 - 部署:API 进程做准入(配额 / 归属 / 幂等 / 预扣)后入队,worker 进程消费并跑 AgentLoop,事件经 Redis 背板回推 WebSocket。
- 演进: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.pydocstring)。 - 修法:阶段 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(D2580e857),迁移期两套并存。
⑤ 状态散在十几个模块级 dict 里
- 问题:一次 run 的收货国、槽表、候选登记表、预算计数各存各的,清理要写 9 个 reset。
- 坏法:漏清一个,下一轮 planner 还没跑就按上轮结论走。
- 修法:减法 D4(
a6d7f49)收成app/api/run_state.py一张表,14 个 dict → 1,9 个 reset → 1。 - 代价:多一层间接访问;
_diagnostics._CHANNEL因键语义不同刻意留在外面。
机制怎么跑(一次请求的分层路径)#
每层一行,括号里是它住在哪个文件。
① 浏览器 React + Vite 对话框 / AGUI 事件流 / 商品卡 frontend/
② HTTP 入口 POST /api/task app/api/server.py:create_task(675)
├ 配额闸 _enforce_quota app/api/server.py:349
├ 归属 _claim_thread_if_needed → claim_thread app/db/accounts.py
├ 预授权 _acquire_hold_or_reject(credit 预扣 + 用户级并发) app/db/holds.py
├ 唯一真相 claim_thread_run(threads 条件更新) app/db/runs.py
└ 幂等窗口 dedup.check_duplicate(Redis SET NX) app/api/dedup.py
③ 队列 Redis Stream 入队,立即返回 thread_id app/queue/redis_stream.py
④ worker 进程 XREADGROUP 领任务,并发 WORKER_CONCURRENCY=4 app/worker.py:58
⑤ 一轮任务 run_agent → _run_turn app/agent/orchestrator.py:277 / 319
├ 作用域 thread_scope / platform_scope / turn_span app/api/context.py
├ 状态清理 reset_run_state()(开局与 finally 各一次) app/api/run_state.py
├ 续聊恢复 load_session_state(session_dir) → AgentState app/agent/session_io.py
└ 装配 build_main_agent + build_toolkit() app/agent/agents.py / tool_registry.py
⑥ AgentLoop ReActConfig(max_iters=MAIN_MAX_ITERS=30) app/agent/limits.py
├ 控制面 25 hook / 6 hook 点 app/harness/hooks/
├ 机制步 开局预取 category_insight、检索后自动比价精挑 app/harness/prefill.py / autopick.py
└ 收尾 TERMINAL_TOOLS 六选一即停 app/agent/constants.py:27
⑦ 工具 19 个业务工具 + MCP 只读白名单 app/tools/ · app/agent/mcp_registry.py
⑧ 检索 / 记忆 / 网关 Qdrant 召回 + reranker + 两级缓存 app/recall/
memory_facts 事实库 + 会话级 P_t app/memory/
LiteLLM Router 寻址 + 断路器 + 双令牌桶 app/agent/llm.py · gateway.py
⑨ 产物与事件 session.json 落 ARTIFACT_ROOT/<thread_id>/ app/agent/session_io.py
AGUI 事件 → Redis 背板 → WS /ws/{thread_id} app/api/monitor.py · backplane.py · connection.pyplaintext两条规矩记住: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/06 | LangChain/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 ddaf043 | research(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 c300282 | memory_facts 表 + validate_fact 单门 + tier-one 注入 + 回合后抽取 + 保留期 + 偏好页事实表单 | key 即身份,判重规则不再自己长;fact_key 列名避开 MySQL 保留字 |
| S skill | 53a48ea 8b851f4 3943ecf 451b6d5 c922059 | 3 个新 skill + system prompt 瘦身;删 skill 预注入改模型自觉 + 同轮发出;present_guide 终结工具;触发验收集(正例 11/13 → 13/13) | 预注入让同一份正文在 3 轮会话里躺 6 次;机制只管事实接地,打法交模型 |
| 阶段 1 多副本 | 9ddd290 b79efb7 c15ac5b e9022bb a2b4ed8 f9efe2b 0978dc1 83378ac | run_holds / threads 条件更新 / interrupted 收尾 / ARTIFACT_ROOT / 四场景验收 / SQLite→MySQL 搬数 / 删单机分支 / 参数覆盖与库对账 | 多副本是唯一形态,进程内不留真相 |
| 阶段 2 网关 | e1cc9e8 f9fb447 5ba2df6 770163f 8ea5504 92a2a7d df21fae c869135 | LiteLLM spike 四判据 → Router 寻址;断路器只对「对面挂了」计数;fallback 能力门;双令牌桶(RPM+TPM 同生共死、预扣结算) | 每进程限流在多副本下等于 N 倍速率,429 由此而来 |
| 阶段 3 写边界 | bfa99c3 | 确认卡按 run_id 幂等,订单号撞了重分不覆盖 | 整轮重跑不能落两张卡 |
| 阶段 4 依赖与超时 | 307d12d 383ba8b d40b205 191b817 30e1a41 | 入队等待上限(作废并原路退预扣)、deadline 下传、检索依赖报「别重试」、Qdrant/OpenSearch 各挂断路器、request_id 随队列过去 | 两个进程的日志要接成一条线;挂了别每次等满超时 |
| SLO | c55a1ff | run 成功率 + 首事件延迟两条,口径先定死 | 分母只算 success+failed,cancelled 与 dependency_rejected 排除 |
| 减法 | 91616db(A) 83f6678(C1) 408c99d(C4) e29fcba597656c(D1 | 删跑不起来的示例与 spike;server.py 1779→1106 行拆出 3 个 router;删只剩一个值的 role 参数;检索计数只留一条路;item_picker 420 行拆七阶段;run 状态收成一张表;env 键 253→222 | 单环之后的残余不删,下一个人会照着它建模 |
| Skill 页 | 0d50ff4 | Skill 页列出系统内置 skill + 只读详情 | 用户看得见有哪些方案可选 |
数字与证据#
| 数字 | 指什么 | 来源 | 状态 |
|---|---|---|---|
| 19 | _BUSINESS_TOOLS 元素个数 | app/agent/tool_registry.py | ✓ 自己数过 |
| 6 | TERMINAL_TOOLS 元素个数 | app/agent/constants.py:27 | ✓ |
| 25 / 6 | harness hook 个数 / hook 点个数 | grep -c '@harness_hook' app/harness/hooks/*.py | ✓ |
| 30 / 300s | MAIN_MAX_ITERS / MAIN_AGENT_TIMEOUT_SEC 代码默认 | app/agent/limits.py | ✓(.env 把超时设成 600,旧版核对过) |
| 4 / 330s | WORKER_CONCURRENCY / WORKER_GRACE_SECONDS | app/worker.py:58,62 | ✓ |
| 190 / 36,098 | app/ 下 Python 文件数 / 总行数 | find app -name '*.py' | xargs cat | wc -l | ✓(2026-09-22) |
| 103 | tests/ 下测试文件数(非用例数) | ls tests/*.py | wc -l | ✓ |
| 17 | Alembic 迁移条数 | ls migrations/versions | ✓ |
| 7 | 系统内置 skill 目录数 | ls skills/ | ✓ |
| 63 | 2026-09-16 起非合并提交数 | git log --since=2026-09-16 --no-merges | ✓ |
| 1779 → 1106 | server.py 拆 router 前后行数 | 83f6678 提交标题 | ✓ 提交正文;现为 1110 行(其后又有改动) |
| 253 → 222 | app 读的 env 键数 | 597656c 提交标题 | 仅口径(未自行复算) |
| 14 → 1 / 9 → 1 | run 状态 dict 数 / reset 函数数 | a6d7f49 提交正文 | 仅口径 |
| 440 / 60 / 0 | 删派发依据:TradeAgent 会话数 / SearchAgent 派发数(全单跳)/ 真实用户定点调查次数 | 旧版手册核对过,A 系列提交正文 | 旧版核对过 |
| 52.6s / 74.8s | split / clone 平均墙钟(已被单环推翻,只作演进证据) | data/eval/worker_mode_*.json | 旧版核对过;样本小 |
| 11/13 → 13/13 | S4 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 只能拦「派了没启用的平台」,删派发时一并去掉。现在靠提示词与评测兜,属于已知缺口。
坑与易混点#
- 文档落后于代码:
docs/ARCHITECTURE.md还写BUSINESS_TOOLS (15)、「十二大工具」、main_agent.run_agent(),实际是 19 个工具、入口在app/agent/orchestrator.py。 CLAUDE.md写「十八大工具」,代码是 19 个(S3 加了present_guide);该文件当前未进 git(git show HEAD:CLAUDE.md报不存在)。run_state.py在app/api/不在app/harness/,虽然它服务的是 run 级状态。- 超时数字有两份:代码默认 300s,
.env设 600s,别只报一个。 - 子 Agent 相关材料还在仓库里(
docs/milestones/M2、M25、scripts/archive/),是被推翻的中间态,别照着迁回来。
本章和别章的接口#
- 主循环与终止安全在第 2 章;单环收敛细节与写边界在第 3 章。
- 检索管道在第 4 章,记忆事实库在第 5 章,控制面 hook 在第 6 章。
- 网关 / 断路器 / 令牌桶在第 9 章,多副本与队列在第 10 章,Skill 体系在第 14 章。