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

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

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

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

最开始按 FAQ 问答做

LibreDesk 后端是 Go,HTTP 使用 fasthttp 和 fastglue,数据库是 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,少一次候选排序而已

后来我把 intent 和 requires_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_name、product_code、size、quantity、attributes,后端再按顺序组成多组搜索词

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,第三次继续问价格,或者直接问 coupon、discount,再给优惠券

价格轮次保存在 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 pieces、requires_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 了,也不想再看日志和重复测试,我感到疲惫