我如何打造 DigiKedai Telegram AI 客服机器人

· 2 min read · case-studies telegramdockercloudflarelitellmtypescriptai

Digi Kedai 销售数字产品——在线课程、电子书、模板——每一单都会带来一连串同样的顾客问题:「这门课有免费试看吗?」「我怎么拿到账号?」「我该买哪个套餐?」。对一个人的生意来说,人工回复根本忙不过来。于是我打造了一个 AI 客服机器人,全天候解答这些问题,甚至能在无人介入的情况下自动发放免费试用账号。

这篇讲的是它是怎么搭起来的——架构、LLM 接线,以及那些各自吃掉我一整个下午的 bug。

为什么企业需要一个这样的机器人

讲架构之前,先说为什么。客服机器人不是噱头——它改变了小生意的经济学:

对 Digi Kedai 这样一个人的生意来说,这就是在凌晨两点丢单和成交之间的差别。

技术栈

架构

顾客 → 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.445s 超时。

机器人知道自己的商品目录,但不含价格和内部路径。 一个构建时的生成器把单一的 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 擅长开放式问题,却很不擅长状态。所以那些需要可靠的对话环节——购买、试用领取、深链——都是确定性状态机,而非提示词工程:

我会做不同的地方

结果

现在 NAS 上的一个容器就能跨两个通道(Telegram + WhatsApp)解答顾客问题,按 SKU 或自然语言检索到正确商品,并端到端发放免费试用账号——还有一套约 175 个单元测试覆盖确定性流程、检索和 payload 契约。源码在 git.hoelee.com/hoelee/digikedai-bot

如果你也在打造一个 LLM 驱动的 Telegram 机器人,教训其实很朴素:模型是最容易的部分。webhook 生命周期、payload 契约、以及开通流程的幂等性才是真正会出问题的地方——先设计好这些。


想为你的生意做一个这样的机器人吗?

我为企业定制 Telegram/WhatsApp AI 机器人、网站,以及自托管基础设施。如果一个这样的机器人能为你省时省钱——或者你想雇佣我——我非常乐意交流:

Lee Teong Hoe

Full-stack developer & DevOps engineer. I build web apps, self-host infrastructure, and automate things — this blog is my living portfolio.