最开始,领导说一周内搭一个 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 了,也不想再看日志和重复测试,我感到疲惫