项目 / MewHelp

MewHelp · 电商智能客服 Agent

从一条纯对话接口起步,逐章长成一个能用的客服系统

2026
课程项目 · 主线 FastAPI
10 递进章节
6727 应用代码行
48 测试文件
46 Make 任务

这个项目做了什么

MewHelp 是一个面向电商场景的智能客服系统。用户在聊天页问一句,后端会判断他到底想问什么, 决定要不要去查订单、检索知识库、或者走退款流程,最后把结果组织成人话回答出来。

整个项目是按章节逐步长出来的,不分支、不推倒重来。第一章只有一条对话接口, 到第十章收尾时已经是完整的一套东西 —— 这也是我觉得它比「一次写完的玩具项目」更有价值的地方: 每一章都在解决上一章暴露出来的真实问题。

需要说清楚的是:这是一个课程项目,我按课程章节的顺序复现并在此基础上做了二次开发。 项目的骨架与章节划分来自课程,我在每一章里补了设计文档、实现计划和评审记录, 并在若干处(上下文分组裁剪、工具重试边界、嵌入模型进程级复用)做了自己的判断和改动。

架构是怎么一步步长出来的

每一章都因为前一章不够用才存在。下面按依赖顺序排开。

CH01 对话

先把一次对话做稳

流式输出让用户不用干等,多轮上下文让第二句话接得住第一句,历史按 token 预算裁剪避免越聊越贵。

SSE多轮上下文结构化抽取
CH02 工具

让它会查数据

把业务能力包装成工具,让模型自己决定调哪个。关键词查 FAQ 暴露出同义说法漏召回的问题,留给下一章。

Function Calling工具注册表MySQL
CH03 检索

从关键词换成向量

文档切块、嵌入、存向量库,检索按语义而不是字面。工具对外的契约一行没改。

BGE-M3Milvus双写状态机
CH04 质量

召回要宽,排序要准

BM25 和向量双路召回后用 RRF 融合,再上重排模型精筛。配套一整套检索评估。

BM25RRFReranker引用溯源
CH05 编排

把稳定的部分焊死

单轮工具调用接不住「先查订单拿单号、再查物流」这类多步任务。改成图编排:确定性骨架加一个主力 Agent 节点。

LangGraphStateCheckpointer
CH06 理解

听懂人话

「它能退吗」脱离上下文毫无意义。加入指代消解、意图分流,缺订单号时弹选择器让人点,不让模型猜。

指代消解意图识别interrupt/resume
CH07 上下文

聊久了不能爆

全量历史递给模型必然撑爆。改成双层:最近几轮留原文,更早的异步压成摘要。

滑动窗口滚动摘要前缀缓存
CH08 工具系统

工具成为生态

几个写死的工具撑不起真实业务。统一注册、校验、鉴权、执行、审计,并用 MCP 接外部工具。

MCP权限分级审计日志
CH09 可观测

照亮黑盒,回收问题

接 Langfuse 看每次调用的完整链路,按意图算 token 账;答不上来的问题进池子,人审后补回知识库。

Langfuse成本账数据飞轮
CH10 微调

该训就训

池子里的问题按 17 类主题归类,决定先补哪块知识。说法无穷、类目固定 —— 微调小模型比提示词划算。

RoBERTa微调ONNX 推理

我解决过的工程问题

这一节是我觉得最有价值的部分 —— 它们不是「功能做完了」,而是「这东西真的能跑起来」。

上下文裁剪把工具调用链拆散,导致之后每一轮请求都失败

CRITICAL
问题
多轮对话要按 token 预算裁剪历史,原实现按「一问一答」成对删除。
后果
一旦对话里有工具调用,这个假设就不成立 —— 窗口会把 assistant(tool_calls) 和对应的 tool 消息拆开。超限后窗口以 tool 行开头,被上游拒收,之后每一轮都报错。
定位
来自最终全流程评审。根因是设计文档里写了「复用上一章的裁剪逻辑」,但那一章没有工具调用,成对裁剪在工具结构下不成立。
方案
升级为分组感知裁剪:一个组 = user,或 user + assistant(tool_calls) + tool… + assistant。保证窗口必然以 user 开头,当前轮永不删除。
验证
补 8 个边界用例,全量测试通过,重启服务实测多轮链路正常。

工具超时取消不了同步调用,写操作被重试了两次

IMPORTANT
问题
工具执行引擎统一做了超时加重试。但 asyncio.wait_for 只能取消协程,取消不了跑在线程里的同步工具。
后果
超时触发重试后,一个已经执行过的写操作会被再执行一遍 —— 静默的数据重复,日志里看不出异常。
方案
引入构建期的 non_retryable 黑名单,把有副作用的工具排除在重试之外。读操作照旧重试,写操作绝不。
取舍
为什么不直接对所有工具都不重试?因为读操作超时重试是安全的,放弃重试会明显降低成功率。

嵌入模型每轮请求重建,每轮重载几 GB 权重

IMPORTANT
问题
检索链路里,嵌入模型是在每次请求时构造的。
后果
权重加载一次约十几秒,而且向量库连接会随对象一起泄漏。第一个知识类问题会直接超时失败。
方案
改为进程级复用嵌入模型与向量库连接,并引入延迟连接的包装。同时把冷启动时阻塞事件循环的同步计算挪到线程池。
验证
用真实嵌入模型加向量库跑端到端冒烟,换说法的问句稳定命中。

测试替身把 bug 藏起来了

IMPORTANT
问题
模块重构后,一个调用方用的还是已被删掉的旧方法名,但我把测试里的替身对象写得比真实实现更宽容,所以测试全绿。
后果
测试通过,但线上路径一调就崩。这是最难发现的一类问题 —— 你以为有测试保护,实际上测试保护的是错的接口。
方案
把替身对象重建为镜像真实实现的接口面,返回类型也要一致。测试的价值在于复刻真实约束,不是模拟一个更好说话的世界。

一段关键实现

工具执行的第一阶段:跨 chunk 合并工具调用,并对无法解析的调用主动失败。

async def stream_first_round(model_with_tools, messages, on_delta):
    """第一阶段:流式收集工具调用,同时逐 chunk 回调文本。"""
    acc = None
    parts = []
    async for chunk in model_with_tools.astream(messages):
        acc = chunk if acc is None else acc + chunk   # 跨 chunk 合并
        text = _text_of(chunk)
        if text:
            parts.append(text); on_delta(text)

    calls   = getattr(acc, "tool_calls", None) or []
    invalid = getattr(acc, "invalid_tool_calls", None) or []

    # 工具调用 JSON 无法解析时,处理不当本轮会静默无输出
    if not calls and invalid:
        raise ValueError(f"工具调用 JSON 无法解析:{invalid}")

    return normalize(calls), "".join(parts)

已知限制

主动写清楚边界,比声称「完美」更接近工程现实。

  • 关键词降级路径对同义表达仍会漏召回 —— 这正是引入混合检索的直接原因,但降级路径本身保持原样未升级。
  • user_id 目前没有鉴权,会话归属只按 id 过滤。这是课程项目的简化设计,不是生产形态。
  • 会话级并发锁覆盖了同一 session 的写冲突,但没有做跨实例的分布式协调。
  • 嵌入与重排模型绑定本机缓存路径,换环境需要重新下载权重或改配置。