Add Chinese translation of DigiKedai bot post; center search icon
Deploy / build (push) Successful in 21s
Deploy / build (push) Successful in 21s
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
---
|
||||
title: "我如何打造 DigiKedai Telegram AI 客服机器人"
|
||||
description: "一个全天候解答顾客问题、并在 Telegram 和 WhatsApp 上自动开通免费试用账号的 AI 客服机器人——TypeScript、grammY、Cloudflare 隧道 webhook,以及自托管的 LiteLLM 网关。"
|
||||
pubDate: 2026-09-06
|
||||
category: case-studies
|
||||
tags: [telegram, docker, cloudflare, litellm, typescript, ai]
|
||||
---
|
||||
|
||||
Digi Kedai 销售数字产品——在线课程、电子书、模板——每一单都会带来一连串同样的顾客问题:*「这门课有免费试看吗?」「我怎么拿到账号?」「我该买哪个套餐?」*。对一个人的生意来说,人工回复根本忙不过来。于是我打造了一个 AI 客服机器人,全天候解答这些问题,甚至能在无人介入的情况下自动发放免费试用账号。
|
||||
|
||||
这篇讲的是它是怎么搭起来的——架构、LLM 接线,以及那些各自吃掉我一整个下午的 bug。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **TypeScript + Node 20**,作为单个容器跑在我的 Synology NAS 上。
|
||||
- **grammY**——一个轻量的 Telegram Bot 框架,用 webhook 模式接线。
|
||||
- **Hono**——一个极小的 HTTP 服务器,负责接收 webhook 并响应 `/health`。
|
||||
- **Cloudflare 隧道**——`bot.digikedai.com` 直连容器;无需公网 IP,无需开放端口。
|
||||
- **LiteLLM**——一个位于真实模型之前的自托管网关,这样我可以不碰机器人代码就切换供应商或加故障转移。
|
||||
- **PostgreSQL**——对话与用户记忆存储,与我的 mem0 栈共用。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
顾客 → Telegram/WhatsApp → Cloudflare 隧道 → bot.digikedai.com
|
||||
→ Hono POST /<secret>/webhook → grammY 处理器
|
||||
→ 存入 PostgreSQL → Agent.respond(系统提示词 + 检索到的商品 facts + 最近 20 条消息)
|
||||
→ LiteLLM(模型别名 "mem0-openai")→ 持久化 + 回复
|
||||
```
|
||||
|
||||
几个值得解释的决策:
|
||||
|
||||
**Cloudflare 隧道直连容器,不经过 Traefik。** 我的 homelab 在 CGNAT 后面,没有隧道就没有任何东西能被公网访问。webhook URL 藏在一个**秘密路径段**(`/<secret>/webhook`)后面,这样 Telegram 的更新只有知道这个 secret 的人才能送达——这是在 Telegram 自带 token 认证之外的第一道廉价防线。
|
||||
|
||||
**模型用 LiteLLM 的*别名*寻址,从不用原始模型名。** 机器人调用 `mem0-openai`;LiteLLM 把它映射到真实模型(背后还有 OpenRouter 故障转移)。机器人永远不需要知道实际是哪个供应商在服务请求。我设了 `temperature: 0.4` 和 `45s` 超时。
|
||||
|
||||
**机器人知道自己的商品目录,但不含价格和内部路径。** 一个构建时的生成器把单一的 `catalog_sku.csv`(唯一数据源)转成机器人导入的 TypeScript 模块——只含 SKU、名称、分类、大小和商品 URL,*别的什么都不带*。价格从不定死(「以页面当前价为准」),内部资源路径从不会进机器人镜像。这让打包体积精简,也防止模型泄露内部结构。
|
||||
|
||||
## 检索:机器人如何真正*知道*商品目录
|
||||
|
||||
把整个目录塞进提示词里的通用 LLM 会瞎编。所以机器人先检索、再回答。它有一个三档检索器:
|
||||
|
||||
1. **SKU token 匹配**——`/\b[A-Z]{2,}\d{2,}\b/gi` 能精确命中 `CZH01` 这样的 SKU。
|
||||
2. **整句子串匹配**——针对短而精确的查询。
|
||||
3. **分段匹配**——切分 CJK 连续串(剥离「有/吗/哪些」这类疑问助词),并过滤英文停用词。
|
||||
|
||||
前 5 个匹配结果成为注入系统提示词的「facts」,模型被要求*只*基于实际检索到的内容回答——查不到时也要明说。
|
||||
|
||||
这比看起来更重要。顾客问*「CZH01 有免费版吗?」*之所以能得到关于 `FREECZH01` 的正确回答,是因为检索器在命中付费 SKU 时,会**自动附带它的免费 twin** 并排在第二位。这个细节把一句「抱歉,没有」变成了正确的向上销售。
|
||||
|
||||
## 免费试用开通——全程无需人工
|
||||
|
||||
我最引以为豪的部分:机器人不只是*谈论*免费试用,而是真正*发放*它们。顾客有三种方式触发——`/trial` 命令、像*「我要这个试用:digikedai.com/products/czh01」*这样的自然语言消息,或者 `?start=CZH01` 这样的深链。
|
||||
|
||||
机器人不经过 n8n,而是**直接写入 NocoDB**——插入一条顾客记录和一条顾客-商品记录。我已有的 webhook 接手并自动开通真实账号,所以机器人从不碰文件服务器。整个过程是幂等的:如果顾客已有账号,就复用它而不是新建重复的;如果商品插入失败,就回滚顾客记录,这样重试不会卡死。
|
||||
|
||||
甚至还有多账号选择器。如果同一个 Telegram 用户有好几个账号,机器人会把它们列成内联按钮,问要把试用挂到哪个账号上。
|
||||
|
||||
## 那些各自吃掉我一整个下午的 bug
|
||||
|
||||
上线并不顺利。有三个值得讲的调试故事:
|
||||
|
||||
**1. 无限回复循环。** 如果服务器没在超时窗口内确认,Telegram 会重新投递同一条 webhook 更新。我的处理器跑得久(LLM 延迟),于是 Telegram 重发了同一条消息——机器人就一遍又一遍地回答它。修复是 `onTimeout: "return"` 加 50 秒窗口,这能让重投递的螺旋戛然而止。
|
||||
|
||||
**2. 模型别名 400。** 直接调用原始模型名(`gpt-5-mini`)返回 400。只有 LiteLLM 别名能通。这现在是仓库里的一条硬性规则:*LLM 必须用别名,从不用原始上游名。*
|
||||
|
||||
**3. WhatsApp 的「m_text」bug。** 当我加 WhatsApp 通道(通过一个把 WhatsApp Web 事件 POST 到 webhook 的浏览器扩展)时,一个「自定义 webhook payload」设置里空字符串的模板值让扩展把字面字段*名* `m_text` 当成了消息文本——于是机器人回复*「我不明白 m_text」*。修复是关掉那个设置、改用默认 payload,适配器就能正确读取了。
|
||||
|
||||
三个故事共同的诚实结论是:故障不在难的部分——LLM 或检索。而在 **webhook 生命周期和 payload 契约的细节**,那些集成真正出问题的无聊边角。
|
||||
|
||||
## 在 LLM 之上叠加确定性流程
|
||||
|
||||
LLM 擅长开放式问题,却很不擅长*状态*。所以那些需要可靠的对话环节——购买、试用领取、深链——都是**确定性状态机**,而非提示词工程:
|
||||
|
||||
- **购买意图**(`order`、`want to buy`、`buy`…)在进入 LLM 之前就被拦截,驱动一个内联键盘(网店 → 管理员 → 返回),从不给自由文本回复。
|
||||
- **深链**把 SKU 放在 `?start=` payload 里,但 Telegram 那里只允许 `A-Z a-z 0-9 _ -`——一个 `:` 会静默地把它弄坏。我吃过大亏,于是把分隔符从 `buy:SKU` 换成了 `buy-SKU`。
|
||||
- **WhatsApp 里的跑题消息**会让模型输出一个哨兵值(`NO_REPLY`),代理把它变成*「什么都不发」*——这样机器人在没话可说时保持沉默,而不是胡言乱语。
|
||||
|
||||
## 我会做不同的地方
|
||||
|
||||
- **启动时就加 `setMyCommands()`。** 命令列表定义在代码里,但我从没在 Telegram 注册它,所以聊天框里的命令菜单一直是空的,直到我用 Bot API 修好。小事,但因此损失了一个真实的顾客触点。
|
||||
- **在写通道适配器之前先定好 payload 契约。** WhatsApp 的 `m_text` bug 就来自我信任了一个没读过契约的自定义功能。
|
||||
- **把 webhook 超时当作一等架构**,而不是事后补丁——如果我在第一天就想到确认窗口,重投递循环本可以避免。
|
||||
|
||||
## 结果
|
||||
|
||||
现在 NAS 上的一个容器就能跨两个通道(Telegram + WhatsApp)解答顾客问题,按 SKU 或自然语言检索到正确商品,并端到端发放免费试用账号——还有一套约 175 个单元测试覆盖确定性流程、检索和 payload 契约。源码在
|
||||
[git.hoelee.com/hoelee/digikedai-bot](https://git.hoelee.com/hoelee/digikedai-bot)。
|
||||
|
||||
如果你也在打造一个 LLM 驱动的 Telegram 机器人,教训其实很朴素:模型是最容易的部分。webhook 生命周期、payload 契约、以及开通流程的幂等性才是真正会出问题的地方——先设计好这些。
|
||||
@@ -134,6 +134,9 @@ body {
|
||||
line-height: 1.35;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.search-toggle:hover { color: var(--brand); border-color: var(--brand); }
|
||||
.lang-switch {
|
||||
|
||||
Reference in New Issue
Block a user