最开始,领导说一周内搭一个 AI 客服系统,但没有说明这个客服要管售前、订单和售后,公司也没有产品经理,我接到这个需求时按一个简单的知识库问答来理解,做成 FAQ 的形式,客户提问,知识库匹配答案

当时拿到的资料和要求也只够做到这里,我选了 MaxKB 做知识库检索和回答,LibreDesk 负责接收消息和把答案发回去,第一版很快就能跑

后面测试的时候,售前询价、商品搜索、订单查询、退货退款、破损、错发、机器故障这些要求才一点点加进来,结果这一做就是一个多月,现在后端要判断意图、查询商品、恢复上一轮问题、跑订单售后状态机,还要按同一商品的询价次数切换回复

今天还差 Meilisearch 没接,Shopify 搜不到商品时要继续搜索公司的 B2B 网站,我知道代码应该接在哪里,但今晚不想再写了,很累,先记一下这一个多月改了些什么

最开始按 FAQ 问答做

LibreDesk 后端是 Go,HTTP 使用 fasthttpfastglue,数据库是 PostgreSQL,数据访问是 sqlx,Redis 处理登录会话、限流和部分运行时能力,Widget 和客服后台通过 REST、WebSocket 与后端通信

前端在这次改造里没有承担太多判断,它负责把客户消息发给后端,再把正文、商品卡片和按钮显示出来,什么链接可信,按钮能不能执行,现在谈的是哪件商品,是否应该转人工,这些事情全在服务端

最早的链路差不多是这样

客户提问 -> MaxKB 检索知识库 -> MaxKB 回答 -> LibreDesk 发给客户

这个链路回答 How long does shipping take? 没什么问题,回答 What printing settings should I use? 也可能没问题,但是客户只要连续问两轮,或者一句话里同时有 SKU、数量和售后描述,问题就开始出现

例如下面这句

What's the price for 100 pieces of Christmas hanging ornaments?

处理这句话要先拿到 christmas hanging ornaments,把 100 pieces 留给后面的报价,不能拿整个句子去搜索 Shopify,搜到多个商品后让客户选择,客户选完再恢复最开始的价格问题

这些事情全交给 MaxKB 时,它会猜商品,会问客户要 SKU,会忘记刚才的数量,有时还会直接编一个价格,单轮测试可能没事,多问一轮就乱

从这里开始,后端加上意图识别、商品状态和订单售后状态机,MaxKB继续负责知识库检索和回答

意图识别这一块改了很多次

项目现在有十类订单和售后流程,再加一个 product.search

订单流程有未收到确认邮件、物流查询、修改地址、取消订单,售后流程有未收到商品、退货退款、商品破损、收到错货、机器故障、其他售后

Embedding 会把这些流程的名称、说明、必填字段和示例做成向量,再把客户原话做成向量,算出最接近的 Top-K,它只负责缩小候选,不负责拍板

Embedding 选完候选以后,Flow Extractor LLM 会收到客户原话、当前正在进行的流程、还缺哪些字段、本地规则提取结果和候选流程,它只返回 JSON,不直接回复客户

{
  "intent": "",
  "product_name": "Hip Flask Heat Press Machine",
  "product_code": "PLSJH03NLB",
  "size": "",
  "quantity": "",
  "attributes": [],
  "requires_product_resolution": true,
  "relation": "new_topic",
  "slots": {}
}

模型返回以后,Go 代码还会再检查一次,订单和售后优先,优惠券和运费不能误进商品搜索,数量不能混进商品名,商品名称和 SKU 必须能在客户原话中找到依据,模型判断低置信度时也不能直接切换已经进行中的售后流程

Embedding 没配置或调用失败也不会卡住,后端把全部候选交给 Flow Extractor LLM,少一次候选排序而已

后来我把 intentrequires_product_resolution 拆成了两个字段

intent 决定是否进入订单、售后或商品发现流程,requires_product_resolution 决定回答前是否要先去商城确认一件真实商品,这两个字段不拆开,SKU、打印参数、售后故障就会互相打架

下面几句话看着都在说商品,但走法不一样

客户原话 intent 先查商品 后端处理
Show me 20 oz tumblers product.search 展示商品列表
What's the price for 100 pieces of ornaments? product.search 先确认具体商品,再恢复报价问题
Please provide product details for PLIP3D17GKIT 先把 SKU 映射到商品,再回答详情
What printing settings should I use for PLSJH03NLB? 先确认机器,再去知识库回答参数
Do you have a coupon? 直接走优惠券政策
My PLSJH03NLB stopped heating after_sales.machine_fault 故障流程优先,收集订单号和凭证

机器坏了这句话里虽然有 SKU,但不能先弹商品列表,客户现在要处理售后,不是逛商城,这条优先级在提示词和 Go 校验里各写了一份

商品搜索太他妈折腾人了

最开始我把用户整句话送给 Shopify,后来发现价格、数量、购买语气都会污染搜索,再后来改成本地正则提取商品词,新的商品类型一来又会漏

现在商品词也交给 Flow Extractor LLM,它会分别输出 product_nameproduct_codesizequantityattributes,后端再按顺序组成多组搜索词

SKU 或型号
商品名 + 规格 + 属性
商品名 + 规格
商品名
兼容的 search_query
客户原话,仅作为商品发现类问题的末级兜底

例如

I want to buy 50 pieces of 2.75-inch ceramic items with gold thread detailing

会得到

product_name = ceramic items
size = 2.75 inch
quantity = 50 pieces
attributes = gold thread detailing

拿去搜索的是 ceramic items 2.75 inch gold thread detailing,数量跟着原问题保存,等客户选完商品再处理价格

Shopify 这里也换过接口,/search/suggest.json 能搜到一些没上架的商品,店铺网页能搜到的词它有时又搜不到,现在主路径改成 Storefront GraphQL

POST /api/2026-07/graphql.json
search(query: ..., types: [PRODUCT], first: 5, unavailableProducts: HIDE)

主接口会检查 availableForSale、handle 和 onlineStoreUrl,Storefront 没有结果时才使用 /search/suggest.json 兼容兜底,最多接收五条,去重以后给客户展示三条

客户点击商品卡片时,后端只接受上一轮 ai_last_options 中发出去的官方链接,再从 ai_pending_product_resolution 恢复原问题,点击以后继续处理之前那句 100 pieces

现在 Shopify 两个搜索接口都找不到时会回到 MaxKB,下一步改成请求 Meilisearch 搜索 B2B 站商品,搜到就展示 B2B 商品,搜不到再让 MaxKB 处理

这段今晚没写,流程图里用虚线标记

同一句价格,第一轮和第三轮不能一样

领导给的规则很明确,第一次问价格,先给客户匹配商品链接,第二次还问同一个商品,说明客户对价格不满意,让他查看商品页的可选数量,并留下邮箱和 WhatsApp,第三次继续问价格,或者直接问 coupondiscount,再给优惠券

价格轮次保存在 ai_price_inquiry_state,它同时记录商品身份,客户换了商品就清掉旧价格阶段,从第一轮重新算

价格、MOQ、批量报价、优惠券、无法确认当前价格,这些领导明确规定过的话术都在 MaxKB 回答之后由后端检查,命中后直接覆盖草稿,不依赖模型今天有没有记住

商品已经确认,并且 Shopify 能拿到实时价格时,后端会请求 /products/{handle}.js,价格问题还会从 /cart.js 读取币种,然后把商品名、变体、价格、币种和可售状态作为隐藏数据发给 MaxKB

公开接口能确认可购买,不代表它能告诉我精确库存,代码里会把取不到的库存写成未知,不让回答把 availableForSale 说成还剩多少件

客户一开始没有官方链接时,MaxKB 第一遍回答如果识别出可信 Shopify 链接,后端拿链接再查实时数据,然后使用同一个 answer chat ID 请求 MaxKB 改第一遍草稿,客户只看到第二遍

MaxKB分为三种会话

answer 会话回答商品知识、政策、使用方法和已经确认商品的追问,flow 会话处理问候、乱码、低置信度流程切换,option 会话只负责生成回答下面的下一步按钮

三个 chat ID 都跟着 LibreDesk 会话保存,同一角色可以继续上一轮上下文,不同角色不共享记录,不然售后流程问到一半,按钮生成的内容也会污染正常回答

发给 MaxKB 的内容不一定只有客户原话,客户说 this product 时,后端会附上最近确认的商品链接,问实时价格时再附上 Shopify 数据,这些内部数据块在发送给客户前会被删除

MaxKB 如果又问客户要 SKU、product ID 或 model,后端会把它改成商品名称或官方商品链接,MaxKB 如果输出内部转人工暗号,后端删除暗号再执行转接

按钮也不能由前端随便传一个 ai_action 就执行,服务端会对照上一轮保存的 ai_last_options,不在白名单里的动作只当普通文字处理

现在这条链路到底长什么样

几天前那张流程图已经不准了,当时还没有统一商品解析和 Storefront 主搜索,下面这张按当前 service.go 的执行顺序重新画,虚线的 Meilisearch 分支是下一步,当前代码还没有接

flowchart TD
    A["第 1 步,客户发送文字、按钮或附件"] --> A1["消息写入 PostgreSQL,再触发自动回复"]
    A1 --> A2{"同一会话是否已有任务在处理"}
    A2 -->|是| A3["内存 pending 只保留最新一条消息"]
    A3 --> A4["当前任务结束后重新进入处理"]
    A2 -->|否| A4
    A4 --> B["取得会话级锁并读取 custom_attributes"]

    B --> B1["读取订单或售后流程、语言和已收集字段"]
    B --> B2["读取商品身份,名称、SKU、型号、官方链接"]
    B --> B3["读取待选商品、待选变体和原始问题"]
    B --> B4["读取同一商品的价格阶段"]
    B --> B5["读取 answer、flow、option 三个 MaxKB chat ID"]
    B --> B6["读取上次服务端下发的按钮和最后消息 UUID"]
    B1 --> C{"会话是否已转人工、已结束,或消息已处理"}
    B2 --> C
    B3 --> C
    B4 --> C
    B5 --> C
    B6 --> C
    C -->|是| C1["停止,不再发送自动回复"]
    C -->|否| D["提取文本,纯图片则给出图片问题入口"]

    D --> D1["只接受配置 Shopify 域名下的 products/handle 官方链接"]
    D1 --> D2{"本条是否出现与上次不同的可信商品链接"}
    D2 -->|是| D3["保存新链接,清除旧变体、旧价格阶段和旧商品身份"]
    D2 -->|否| E
    D3 --> E{"客户是否明确要求转人工"}

    E -->|人工关键词或经过服务端校验的按钮| E1{"当前售后流程是否还缺少首次图片或视频"}
    E1 -->|是| E2["提醒上传凭证,并保留再次转人工按钮"]
    E1 -->|否| EH1["进入统一转人工出口"]
    E -->|否| F{"是否选择了上一轮 Shopify 商品卡片"}

    EH1 --> EH2["写入 ai_human_takeover,添加标签并分配人工团队,离线时使用固定留言"]
    EH2 --> Z1

    F -->|是| F1["验证所选 URL 必须来自 ai_last_options"]
    F1 --> F2["从 ai_pending_product_resolution 恢复客户原问题"]
    F2 --> F3["合并商品标题、SKU 和官方链接,进入回答链路"]
    F3 --> MA1
    F -->|否| G{"是否是严格商品详情动作"}

    G -->|Tell me about this product 或 Please provide product details 加唯一官方链接| G1["从链接解析并校验 Shopify handle"]
    G1 --> G2["请求 products/handle.js,读取标题、说明、图片和变体"]
    G2 --> G3["后端直接生成商品详情和商品预览,不调用 MaxKB"]
    G -->|否| H{"是否在等待客户选择 Shopify 变体"}

    H -->|是,且本轮是价格、可售或合法变体选择| H1["恢复待处理问题和当前商品链接,进入回答链路"]
    H1 --> MA1
    H -->|否| I{"同一商品已有价格阶段,客户是否继续议价"}
    I -->|是| I1["沿用当前商品身份和价格阶段,进入回答链路"]
    I1 --> MA1
    I -->|否| J["第 2 步,本地规则提取高置信度流程、槽位和商品线索"]

    J --> J1["提取订单号、物流号、邮箱、国家、原因等字段"]
    J --> J2["识别十类订单或售后表达和常见商品发现句式"]
    J --> J3["初步拆分商品名、SKU、规格、数量和属性"]
    J1 --> K["第 3 步,Embedding 对固定候选做相似度排序"]
    J2 --> K
    J3 --> K

    K --> K1["候选共十一类,六类售后、四类订单、product.search"]
    K1 --> K2["将客户原话向量与候选名称、说明、字段、示例比较"]
    K2 --> K3["取得 Top-K,并补回当前流程、本地命中和 product.search"]
    K3 --> K4{"Embedding 是否可用"}
    K4 -->|否| K5["使用全量候选,不中断消息"]
    K4 -->|是| L
    K5 --> L["第 4 步,Flow Extractor 返回严格结构化 JSON"]

    L --> L1["输入客户原话、当前流程、缺失字段、本地结果和候选"]
    L1 --> L2["输出 intent、confidence、relation、language 和 slots"]
    L1 --> L3["独立输出 product_name、product_code、size、quantity、attributes"]
    L1 --> L4["独立输出 requires_product_resolution"]
    L2 --> M["第 5 步,后端合并并校验结构化结果"]
    L3 --> M
    L4 --> M

    M --> M1["订单或售后 intent 优先,并清除商品搜索字段"]
    M1 --> M2["运费、优惠券等政策问题禁止商品确认"]
    M2 --> M3["清洗商品字段,数量不进入 Shopify 搜索词"]
    M3 --> M4["校验 relation、置信度、当前流程和话题切换"]
    M4 --> N{"最终是否需要先确认商品"}

    N -->|product.search 达阈值,或知识问题带可信商品名、SKU| P1["生成有序 Shopify 查询词"]
    P1 --> P2["依次尝试 SKU,名称加规格加属性,名称加规格,名称,兼容词"]
    P2 --> P3["目录发现类问题才把客户原话作为末级兜底"]
    P3 --> P4["优先请求 Storefront GraphQL search,隐藏不可售商品"]
    P4 --> P5{"Storefront 是否返回有效商品"}
    P5 -->|没有| P6["调用 search/suggest.json 兼容兜底"]
    P5 -->|有| P7["校验可售状态、handle、官方 URL,按 handle 去重"]
    P6 --> P7
    P7 --> P8{"是否得到有效 Shopify 商品"}
    P8 -->|有| P9["最多展示 3 张商品卡片,保存原问题、搜索词、商品名和 SKU"]
    P9 --> P10["客户选择后回到商品选择分支 F"]
    P8 -->|没有或请求失败,当前代码| MA1["回退 MaxKB 回答链路"]
    P8 -.->|下一步计划| PB1["请求 Meilisearch 搜索 B2B 网站商品,尚未接入"]
    PB1 -.-> PB2["搜到则展示 B2B 商品,搜不到再回 MaxKB"]

    N -->|否| O{"是否明确命中订单或售后流程"}
    O -->|是| O1["进入 Go 状态机,六类售后或四类订单"]
    O1 --> O2["合并本轮 slots 和真实附件凭证"]
    O2 --> O3{"必填字段是否收齐"}
    O3 -->|没有| O4["后端按缺失字段生成固定提问和合法按钮"]
    O3 -->|收齐| O5["保存资料并进入统一转人工出口"]
    O5 --> EH1

    O -->|没有| R{"是否是低置信度流程切换、问候、乱码或入口澄清"}
    R -->|是| MF1["选择 MaxKB flow 会话,首次使用时调用 open"]
    MF1 --> MF2["发送原话和建议动作,生成说明或确认选项"]
    MF2 --> MF3["后端只保留白名单动作,无有效选项时补固定入口按钮"]
    MF3 --> Z1
    R -->|否| MA1

    MA1 --> MA2["选择 MaxKB answer 会话,首次使用时调用 open"]
    MA2 --> MA3["恢复商品选择前原问题,或使用客户最新原话"]
    MA3 --> MA4["合并本轮商品身份,发现新 SKU、名称或 URL 时清除旧商品价格状态"]
    MA4 --> MA5{"是否应沿用当前商品"}
    MA5 -->|this product、it、价格、详情、兼容等跟进| MA6["追加隐藏商品上下文,禁止再次索要同一商品身份"]
    MA5 -->|明确换商品| MA7["不带旧商品上下文"]
    MA6 --> MA8{"是否问价格、可售状态或库存,并且已有官方链接"}
    MA7 --> MA8

    MA8 -->|是| MA9["请求 products/handle.js,价格问题另取 cart.js 币种"]
    MA9 --> MA10["选择匹配变体,无法唯一匹配时保存候选规格"]
    MA10 --> MA11["把实时商品事实作为隐藏数据块附加到请求"]
    MA8 -->|否或没有官方链接| MA12["准备请求 MaxKB"]
    MA11 --> MA12

    MA12 --> MA13["请求 chat_message/chat_id,re_chat 为 false"]
    MA13 --> MA14["MaxKB 用 answer 会话历史、提示词和知识库生成草稿"]
    MA14 --> MA15{"实时问题在请求前是否没有可信商品链接"}
    MA15 -->|是,且草稿给出可信官方链接| MA16["后端提取链接并查询 Shopify 实时事实"]
    MA16 --> MA17["向同一 answer 会话发送第二次修正请求"]
    MA17 --> Q["第 6 步,审核最终草稿"]
    MA15 -->|否或仍无可信链接| Q

    Q --> Q1["删除隐藏上下文、实时数据块和内部转人工标记"]
    Q1 --> Q2["把索要 SKU、product ID、model 改成商品名称或官方链接"]
    Q2 --> Q3{"是否命中固定价格政策"}
    Q3 -->|批量询价、MOQ、更多数量价格或选商品后问价| Q4["覆盖为固定批量询价文案,并附官方链接,价格阶段记为 2"]
    Q3 -->|coupon、discount,或同商品再次追价| Q5["覆盖为固定优惠券文案,价格阶段记为 3"]
    Q3 -->|无法确认当前价格且没有实时事实| Q6["按商品名称或 SKU 生成固定留言文案"]
    Q3 -->|未命中| Q7["保留清洗后的 MaxKB 回答"]

    Q4 --> Q8{"是否有 Shopify 候选规格"}
    Q5 --> Q8
    Q6 --> Q8
    Q7 --> Q8
    Q8 -->|有| Q9["规格按钮直接来自 Shopify"]
    Q8 -->|没有| Q10["调用独立 option 会话生成 2 至 5 个普通跟进按钮"]
    Q10 --> Q11{"选项是否有效"}
    Q11 -->|否或超时| Q12["使用后端场景兜底按钮"]
    Q11 -->|是| Z1
    Q9 --> Z1
    Q12 --> Z1

    O4 --> Z1
    E2 --> Z1
    G3 --> Z1
    P9 --> Z1
    Z1["第 7 步,保存流程、商品、价格阶段、chat ID、最后按钮和消息 UUID"]
    Z1 --> Z2["正文转义为安全 HTML,官方商品链接生成安全链接"]
    Z2 --> Z3["写入 LibreDesk 回复队列,并附 ai_options、ai_product_preview 等元数据"]
    Z3 --> Z4["Widget 只负责显示正文、商品卡片和下一步按钮"]

再跑一次开头那句话

What's the price for 100 pieces of Christmas hanging ornaments?

本地规则先看出这是品类询价,Embedding 把 product.search 放进候选,Flow Extractor LLM 最后返回商品名 christmas hanging ornaments、数量 100 piecesrequires_product_resolution=true

后端依次请求 Storefront 和 Predictive Search,拿到仍可用的 Shopify 商品后展示三张卡片,同时把原问题保存在 PostgreSQL

客户选择其中一件商品,后端验证这个按钮确实由自己发出,恢复 100 pieces 的价格问题,保存商品标题和官方链接,再进入 MaxKB answer 会话

因为这是选完商品后的批量价格问题,MaxKB 草稿会被固定文案覆盖,客户看到商品页链接和留下邮箱、WhatsApp 的提示,价格阶段保存为第二轮

客户继续问 Can you offer a better price for this product?,后端从会话状态中知道 this product 是刚才那件商品,也知道已经回复过批量询价,这次改用优惠券文案

如果客户换一个 SKU,商品身份发生冲突,后端清掉旧商品的价格阶段,重新搜索和确认新商品

这几句话会经过本地提取、Embedding 候选、结构化模型、Shopify 搜索、按钮校验、状态恢复、MaxKB 回答和固定政策覆盖,漏存一个状态,下一轮就可能丢上下文

写到今晚

这个项目没有产品经理,需求也没有在第一天完整出现,很多规则是一次次测试以后补出来的,昨天正常的十句话,今天换一个商品名称可能又撞出新的分支

我最开始接到的 AI 客服需求没有说明售前和售后,我按 FAQ 做了第一版,后面这一个多月又写了流程识别、状态机、商品身份、Shopify 搜索、实时价格、按钮协议和回答后处理

现在还差 Meilisearch,Shopify 没有的商品继续去 B2B 网站找,接的时候还要处理 B2B 商品链接、卡片字段、可信域名、商品选择状态和后续价格

今晚不接 Meilisearch 了,也不想再看日志和重复测试,我感到疲惫