从开发者调试工具到消费级 App:一个 AI 电商导购项目的 C 端改造与 6 个连环踩坑实录
技术栈:LangGraph + FastAPI + PostgreSQL/Redis/ChromaDB + MCP 工具层 + React/Vite/TS
关键词:AI Agent、MCP、CPS 导购、幂等设计、无状态鉴权、时区 Bug
前言
我手上有一个自己攒的 AI 电商导购项目(代号 shopping-agent):用户用自然语言说"帮我找个 2000 以内的降噪耳机",后端用 LangGraph 编排的 Agent 去搜货、比价、做风险分析,工具层通过 MCP 协议对接商品数据源。
功能一直是能跑的,但一直有个尴尬:它是个"开发者调试工具",不是"能拿给普通用户用的产品"。
具体来说,前端只有两个页面------一个对话页、一个"失败任务排查页",后者还把死信队列的内容直接怼到用户脸上。搜索结果是一堆纯文本商品描述,点不动;没有商品详情;没有个人主页;下单流程压根没实现。
这一天的工作,就是把它从"能用"改到"敢给用户用"。过程里踩了 6 个坑,有几个挺典型,写下来复盘一下。
一、先把路线定清楚:CPS 导购模式
改造前最关键的一个决策不是技术,是产品形态:这个 App 到底要不要在站内收款?
结论是不要 。我们没支付资质,硬做站内收款就是给自己找麻烦。所以定的是经典的 CPS 导购模式:
- 站内只记一笔"意向单"(用户想买哪个商品、哪个平台、什么价);
- 点击"去 XX 购买"跳到第三方平台,真正的交易在那边发生;
- 我们赚的是推广佣金。
这个决策直接决定了后端要设计什么表、前端要做什么交互,后面很多坑都跟它有关。
二、主线:C 端产品化改造 P0--P3
按依赖顺序拆了四个阶段,一天内全部落地。
P0:路由 + 拆页 + 把"失败任务"请出 C 端
原来 App.tsx 是一个巨型组件,现在引入 react-router-dom,拆成七个独立路由:
| 路由 | 页面 | 说明 |
|---|---|---|
/ |
首页 | hero + 分类入口 + AI 导购入口 |
/search |
搜索结果 | 卡片网格 |
/product/:id |
商品详情 | 大图 + 多平台比价表 |
/chat |
AI 导购 | SSE 流式对话 + Agent 推理可视化 |
/orders |
我的订单 | 意向单列表 |
/me |
个人主页 | 画像/订单/盯价聚合 |
/admin/deadletter |
死信管理 | 仅开发者可见,不进 C 端导航 |
关键一点:死信队列(失败任务)绝不能出现在 C 端导航里 。它是给开发者排查用的,普通用户看到"失败任务"只会一脸问号。所以单独挪到 /admin/deadletter 并且加了"开发者管理端"横幅。
同时 App.tsx 改成 NavBar + <Routes> 结构,桌面用 topnav、移动端用 tabbar,nginx.conf 补了 SPA 的 try_files 兜底(否则刷新子路由 404)。
P1:让搜索结果变成"能点的商品卡片"
这一步暴露了后端的一个缺口:原来的 MCP 工具只有 product_search / price_query / review_risk 三个,返回的都是结构化数字,没有图片、没有描述、没有跳转链接。
解决办法不是硬造,而是给 MCP 加第四个工具 product_detail:
python
# mcp_server/main.py(契约头注释节选)
# product_detail {product_ids} -> {"products": [{product_id, name, category, brand,
# tags, description, image, negative_rate, shop_score,
# platforms: {平台: {price, url, url_kind}}}]}
后端再套一层读接口 catalog.py,前端只连后端、不直连 MCP:
python
# backend/app/api/catalog.py
READ_TIMEOUT = 4 # 秒:读接口宁可快失败降级,也不让页面干等
async def _try_tool(name: str, args: dict) -> dict | None:
"""调 MCP 读接口,失败静默返回 None(调用方兜空)。"""
try:
return await mcp_client.call_tool(name, args, timeout=READ_TIMEOUT)
except Exception as e:
logger.warning("商品读接口降级 tool=%s err=%s", name, e)
return None
前端于是有了 ProductCard.tsx(商品图 + 名称 + 平台标签 + 最低价 + 店铺分/差评率)和 PlatformPriceList.tsx(多平台比价表,按价升序、最低价高亮)。
P2:下单链路------"意向单"表
CPS 模式下订单表可以做得非常薄,只存最精简的字段,商品名/图/链接这些会在 API 层用 MCP 现拉:
sql
CREATE TABLE IF NOT EXISTS orders (
id BIGSERIAL PRIMARY KEY,
user_id TEXT NOT NULL,
session_id TEXT,
product_id TEXT NOT NULL,
platform TEXT,
price NUMERIC,
status TEXT NOT NULL DEFAULT 'intent',
created_at TIMESTAMPTZ DEFAULT now()
);
为什么商品快照不落库?因为价格会变、图会变、链接会变,存进去就是一堆过期数据。订单表只记"意向",展示时现查现拼。
交互上,原来的 ApprovalModal(HITL 人工审批弹窗)语义从"审批"改成了"下单前确认",确认后跳 /orders。
P3:个人主页聚合
个人主页要展示"画像偏好 + 订单数 + 盯价订阅 + 历史对话",于是加了 /api/profile 做四源聚合,每个数据源各自降级、查不到给空但不抛 500。同时新建 monitor_subscriptions 表记录盯价订阅。
到这里,P0--P3 主体完工。但真正的故事从这一步才开始。
三、6 个连环坑
坑 1:下单弹窗显示的是商品 id,不是商品名
P0--P3 是先在接口层 + 单测里验的,看着都过。等到浏览器手点走查时才发现 bug:下单确认弹窗上"商品"一栏显示的是 xiaomi-buds-4-pro 这种原始 id。
根因是接口字段和前端读取字段对不上:
- 后端
prepare_order挂上去的是比价表里的一行,字段名是name; - 前端
ApprovalModal.tsx只读product.title,取不到就一路兜底到了product_id。
修法是读取顺序改成 name → title → product_id:
tsx
// 后端 prepare_order 挂上来的是比价表那一行:字段叫 name(不是 title)
const productName = product?.name ?? product?.title ?? product?.product_id ?? '未选出具体商品'
教训很直白:接口层 + 单测全绿,不代表功能是对的。 前后端字段约定这种事,只有真点一遍才靠得住。
坑 2:意向单不幂等,审批被重复点就落多单
HITL 弹窗有个天然风险:用户手抖点两次"确认购买",或者前端网络重试,就会调两次 POST /api/orders,落两笔一模一样的意向单。
一开始想在前端做防抖,但前端防抖挡不住重试和并发,得在后端做真正的幂等。用 PG 的部分唯一索引:
sql
-- 同一会话同一商品只记一笔:只约束有 session_id 的行,手动建单不受影响
CREATE UNIQUE INDEX IF NOT EXISTS uq_orders_session_product
ON orders (session_id, product_id) WHERE session_id IS NOT NULL;
写入时用 ON CONFLICT DO NOTHING,撞键就把原单捞回来返回:
python
cur.execute(
"INSERT INTO orders (user_id, session_id, product_id, platform, price, status)"
" VALUES (%s, %s, %s, %s, %s, %s)"
" ON CONFLICT (session_id, product_id) WHERE session_id IS NOT NULL DO NOTHING"
f" RETURNING {_COLUMNS}",
(user_id, session_id, product_id, platform, price, status),
)
row = cur.fetchone()
if row is None: # 撞唯一键:这轮对话已经下过这一单,捞回原单
cur.execute(
f"SELECT {_COLUMNS} FROM orders WHERE session_id = %s AND product_id = %s LIMIT 1",
(session_id, product_id),
)
row = cur.fetchone()
顺带把商品校验也补了:把"数据源降级"和"真没这个货"分开,降级返回 503(可重试),查无返回 404,两种情况都不写空单(之前不存在的商品也会写一条 platform 为 NULL 的脏数据)。
坑 3:盯价状态"撒谎"
个人主页有"我的盯价"列表,展示哪些商品在监控中。原来的 active 字段恒为 true------因为它直接读的库里的值。
问题是:盯价任务是进程内 的(APScheduler 的内存 job),服务一重启内存里啥都没了,但数据库里那条订阅记录还在。结果就是明明没在监控,页面还显示"监控中"。
修法是:展示前统一用调度器的真实状态覆盖:
python
def is_registered(session_id: str) -> bool:
"""这个会话的盯价任务当前是否真在跑。
调度任务是进程内的,服务重启后内存里啥都没了------这时候返回 False,
订阅记录就不能再显示「监控中」(active 要照实反映,不能撒谎)。
"""
return bool(_scheduler and _scheduler.get_job(f"monitor:{session_id}"))
同时补了 DELETE /api/monitor/{id},取消盯价时同时摘掉内存任务和订阅记录。前端每条盯价加了"取消"按钮。
这个坑的价值在于它定义了一条原则:UI 显示的状态必须是它真实的状态,不能是"我们希望的"状态。
坑 4:个人接口在裸奔,谁都能查别人
/api/profile、/api/orders、/api/monitor 一开始只认 query 里的 user_id,也就是说:
GET /api/profile?user_id=victim # 直接看到别人的画像和订单
demo 阶段没有注册登录,但也不能就这么裸奔。方案是发一个无状态匿名身份 token:
python
def _sign(user_id: str) -> str:
sig = hmac.new(_secret(), user_id.encode(), hashlib.sha256).hexdigest()[:32]
return f"{user_id}.{sig}"
def _verify(token: str) -> str | None:
"""验签:过了返回 user_id,伪造/串改返回 None。"""
user_id, _, sig = token.partition(".")
if not user_id or not sig:
return None
expect = hmac.new(_secret(), user_id.encode(), hashlib.sha256).hexdigest()[:32]
return user_id if hmac.compare_digest(sig, expect) else None
token 是 user_id + HMAC-SHA256 签名,无状态、不落库 :随机 user_id 猜不到,签名验不过就是假的,Redis 挂了也不影响。前端首次访问 POST /api/auth/anon 领一个存 localStorage,之后所有请求带 X-User-Token。
关键是个人接口一律以 token 里的身份为准,外部再传 ?user_id= 也不认。订单详情和取消盯价还补了归属校验------查别人的单/订阅一律 404,不泄露"这东西存不存在"。
坑 5:数据池"永远不更新"------一个时区 Bug
这个是最精彩的。用户反馈"很多东西搜不到",我先按"数据覆盖不够"去查,结果挖出一个真 bug。
数据源接的是京东联盟的京粉精选频道池,构造请求要用 MD5 签名 + 时间戳。代码里原来是:
python
# 改前
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
看起来没问题。但 mcp 容器默认时区是 UTC ,所以 localtime() 出来的是 UTC 时间,而京东网关要求的是 GMT+8。
后果:时间戳差 8 小时,网关判"时间戳参数与服务器时间差异大",全量拒绝。表现就是池子长期只能吃旧缓存、翻页和扩池完全无效------"有 423 条但永远不更新"。
修法是显式按 GMT+8 输出:
python
def _now_gmt8() -> str:
"""京东网关要求时间戳按 GMT+8;容器默认时区是 UTC,直接 localtime 会差 8 小时,
被网关判「时间戳参数与服务器时间差异大」直接拒掉------所以这里显式按 GMT+8 出。"""
return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(time.time() + 8 * 3600))
修完之后,扩池立刻生效:探测范围 1..40 → 1..60、有效频道 8 → 16、每频道翻页 3 → 5,真实商品 423 → 734 条。
这个 bug 值得单独说:一个时区差异,不报错、不崩溃,只是静默地把功能变哑巴------最难查的就是这种。
坑 6:搜索漏召回------单向子串
搜"自行车"搜不到"凤凰自行车坐垫",原因在匹配逻辑是单向子串:要求"关键词 ⊆ 查询词"。
商品类目叫"自行车配件","自行车配件"并不是"自行车"的子串,所以命中不了。改成双向子串,再补几个规则:
python
def _substr_hit(term: str, kw: str) -> bool:
"""词与关键词双向子串命中;单字只认完全相等,免得搜「书」命中 tag「图书」。"""
if not term or not kw:
return False
if term == kw:
return True
if len(term) < 2 or len(kw) < 2: # 单字不做子串,噪音太大
return False
return term in kw or kw in term
再加上"多词 AND 切词"和"类目层优先、整个空才回落到商品名":
python
def tool_product_search(args: dict) -> dict:
terms = _terms(category)
base = [p for p in _PRODUCTS
if all(_constraint_hit(p, c) for c in constraints)
and (budget is None or _min_price(p) <= budget)]
if not terms:
matched = base
else:
keyword_layer = [p for p in base if all(_keyword_hit(p, t) for t in terms)]
if keyword_layer:
matched = keyword_layer # 类目/品牌/tag 命中优先
else:
matched = [p for p in base if all(_name_hit(p, t) for t in terms)] # 空了才回落 name
matched.sort(key=_min_price)
return {"product_ids": [p["product_id"] for p in matched[:30]]}
改前 → 改后:面膜 10→12、零食 6→18、耳机 5→10、洗面奶 0→1(name 回落生效) 、书 4→0(单字噪音消除)。
附赠一个:"诚实标注"------不让按钮撒谎
还有一个体验上的坑:mock 商品(以及任何拿不到联盟精准落地页的商品)点"去京东购买",实际落到的是京东搜索页 。按钮文案和落点不符,本质是假购买。
方案不是硬编码第三方精准链接(天猫/拼多多的详情 URL 无稳定规律、改版就 404),而是诚实标注 :给 MCP 契约加一个 url_kind 字段。
python
def _platform_link(p: dict, platform: str) -> tuple[str, str]:
"""返回 (跳转链接, 链接类型),让前端知道点下去落到商品页还是搜索页。
product=数据层给的真实落地页(京东联盟 materialUrl);search=拼搜索页兜底。"""
real = (p.get("urls") or {}).get(platform)
if real:
return real, "product"
tpl = _PLATFORM_SEARCH_URLS.get(platform)
return (tpl.format(kw=urllib.parse.quote(p["name"])) if tpl else ""), "search"
前端按钮文案随落点走:
tsx
去{plat}
{cell.url_kind === 'search' ? '搜索' : '购买'}
有真实落地页就写"去 XX 购买",兜底搜索页就写"去 XX 搜索"。等以后联盟接口返回真实落地页字段,会自动切成"购买",前端代码一行都不用改。
四、收尾:三条测试基线
一天下来,收工时三条基线全绿:
| 端 | 命令 | 结果 |
|---|---|---|
| 后端 | cd backend && python -m pytest tests/ -q |
154 条全过 |
| MCP | cd mcp_server && python -m pytest -q |
21 条全过 |
| 前端 | cd frontend && npm test |
31 条全过,npm run build 通过 |
另外数据库迁移(alembic 0003、0004)都在真实 PG 上应用验证过,离线 SQL 也核对过。
五、总结:这一天的三条主线
回头看,这一天所有的工作其实是同一条主线的三个切面------诚实:
- 接口诚实 :
url_kind让按钮不撒谎;降级返回 503/404 而不是写空单;盯价状态照实反映。 - 状态诚实 :UI 显示的是真实状态(
is_registered覆盖),不是"我们希望的状态"。 - 数据诚实:一个时区 bug 让功能静默变哑巴,修好之后数据才真的是数据。
另外几条工程上的收获:
- 接口层 + 单测全绿 ≠ 功能对:前后端字段约定、真实交互必须手点一遍才靠得住(坑 1)。
- 幂等要做在后端 :前端防抖挡不住重试和并发,部分唯一索引 +
ON CONFLICT才是真幂等(坑 2)。 - 契约不变是重构的底气 :给 MCP 加
product_detail、加url_kind,上游 Agent 节点零改动、测试零网络。工具层和数据层解耦,扩展才不牵一发动全身。 - 最难查的 bug 是"静默 bug":时区差异不报错不崩溃,只把功能变哑巴(坑 5)。这类问题只能靠对"数据为什么不变"的持续怀疑来发现。
最后,C 端改造遗留清单到这一天全部清零。接下来的方向是拿到京东联盟的关键词搜索权限(或者接第三方聚合),把数据覆盖从"日百为主"扩展到全品类。
如果这篇对你有帮助,点个赞让更多做 AI Agent 落地的同学看到。有问题欢迎评论区交流。