🐍 第13章 打造 LINE 聊天机器人
📄 一句话概括

关键词路由严格按顺序:报修 要写在 报 前面,不然宽的那个先截走。 报修流程用一张表描述状态机,改流程只改表,不动代码。一层层 if 是写不动的。 图文选单是一张图加几个矩形区域,坐标按图片原始像素算,图尺寸写错热点就整体偏。

📝 本章和前面几章的关系

前两篇解决了「收得到」和「看得懂」。这一篇解决「回得有用」。 教材第 13 章最后两节是【LINE 在线报修】和【建立 LINE 图文选单】。 演示里的路由、状态机、两张 JSON 都是真跑出来的,JSON 的字段名和结构照官方文档。 只有一处是复刻:选单和按钮在真机上要传到 LINE 后台、还要审核配图, 这里只把要发的 JSON 打出来看结构。

一、关键词路由:顺序就是优先级

演示第一段五句话,命中四条、漏一条:

用户说 命中什么 回什么
我要报修 报修 进入报修流程,先问设备编号
给个报价 报价 发一份价目表图片
转人工 人工 转人工,记录工单
你好啊 你好 打招呼加一句提示
今天天气不错 没命中 默认回一句「发帮助看能做什么」

匹配是按列表顺序走的,第一条命中就返回。 所以顺序就是优先级。 这就是为什么 报修 必须写在 报 前面。 要是反过来,用户说「我要报修」,报 先命中,你就走了通用报障,问不到设备编号。 这个坑很隐蔽:两条规则都能命中时,你只会在少数句子上发现走错了流程。 所以规则表要按「从窄到宽」排,别按重要性排。

还有那个最后的兜底。 没命中不能什么都不回。 用户发了消息收到一片空白,第一反应是这个号坏了,第二反应是卸载。

二、关键词的天花板

关键词做客服,最大的问题是会漏。用户说「坏了」「不亮了」「跳闸了」,你写十个关键词也盖不全。 第二问题是会错。用户说「报价单我发你了」,报价 命中,机器人却又发一遍价目表。 真实产品会用大模型做意图识别,把「这句话想干什么」判出来,而不是匹配字面。

但关键词这一层永远留着当兜底。理由很实在:大模型会胡说,关键词不会。 而且关键词快、免费、可解释。用户投诉「为什么回这个」,你能指着规则表说清楚; 大模型给你的是一句「我觉得他想报修」。 所以成熟的做法是两层:先走关键词,命中就直接处理;没命中再交给模型。 模型也不确定, 才落到人工。

三、报修流程:用一张表描述状态机

演示第二段四句话:用户说「我要报修」,然后依次给设备号 P-102、说「灯不亮了」、说「好」, 机器人回工单号 WO-20261102-007。 看着像聊天,其实是四个状态在走:等设备号、等问题描述、确认、结束。

第几步 问什么 收完去哪
1 哪个设备(发编号) 第 2 步
2 大概什么问题(可发图) 第 3 步
3 已记录,给工单号 回到第 0 步

这张表就是整个流程,代码只是照表走。 想加一步「方便什么时候上门」,改表就行, 判断逻辑一行不动。 写成一层套一层的 if 也能跑,但加第三步、第五步、加个「中途取消」之后,那个 if 塔就没人敢改了。 这跟 8.1 篇讲的「远离意大利面条」是同一件事:把「规则」和「执行」分开写。 规则是一张表, 执行是一个循环。

四、图文选单:一张图加几块区域

演示第三段打出来的是选单的 JSON 前半截:size 是 2500 乘 1686,areas 里三块 bounds, 每块配一个 action。 一张图配几个矩形区域,点哪块触发哪个动作。 就是这么朴素。 action 常用的三种:message 假装用户发了句话、uri 打开网址、postback 发一个带数据的回调。

为什么报修流程适合 message? 因为它让选单和关键词路由复用同一条路——用户点「报修」,等于打了「报修」两个字,后面那套完全不用改。 坐标是按图片的原始像素算的,跟手机屏幕大小无关。 所以图片尺寸写错, 热点就会整体偏移——图上写着「报修」,点下去跳的是「报价」。 演示里那块被截断的 bounds 就是从第一块开始的。 真发的时候 areas 是完整的,这里只打前 16 行让人看结构。

五、模板消息:让用户点,不用打字

演示第四段是另一种交互:type 是 template,里面套一个 buttons 模板, 按钮的 data 是 dev=light。 用户点了按钮,你不会收到一条文字消息,而是收到 postback 事件,数据在 postback.data 里。 这个区别要记住:message 动作伪装成打字,postback 动作是另一类事件。两条处理路径不一样。

按钮的好处是「不用猜用户会怎么打字」。 让他选,比让他输入可靠得多——尤其是设备名、日期这种有固定选项的东西。 altText 也别省。它是不支持模板的设备上显示的替代文字,也是聊天列表里的预览。 它常常是用户唯一能看到的一句。

六、一个机器人该有的五件事

能力 本课对应 缺了会怎样
验签 13.1 篇 谁都能伪造消息
分流 关键词加状态机 用户说什么都没用
记住你是谁 userId 存状态 每次都从头问一遍
出错兜底 try/except 回一句稍后再试 用户以为你死了
日志 每条事件存一份 投诉时查不到

第四条最容易漏。 机器人抛异常时 LINE 不会替你回话, 用户那边就是「发了没反应」——比回一句「系统忙」糟得多。 一个 try/except 包住整个处理函数,出错就回一句「稍后再试」,再把异常记进日志。 十行代码,救的是整个产品的观感。 第五条是运维的命根子。 上线之后最常问的三个问题——他发的是什么、我回的是什么、为什么没回——全靠日志回答。

日志至少要存下:时间、userId、收到的事件、回出去的内容、有没有报错。 这五样存了,九成的投诉都能当場查清楚。 走到这里,第 13 章就完了。 一个能用的客服机器人 = 验签 + 分流 + 状态 + 兜底 + 日志。 五样缺一样,它就是个玩具。

七、动手 Demo:客服路由与图文选单

先跑关键词路由:五句话,四条命中、一条走兜底。 再跑报修流程:四个状态走完,拿到工单号。 最后把图文选单和按钮模板的 JSON 打出来看结构。

import json

print('  ① 关键词路由:先分流,再处理')
print('  ' + '-' * 68)
rules = [
    ('报修', '进入报修流程,先问设备编号'),
    ('报价', '发一份价目表图片'),
    ('人工', '转人工,记录工单'),
    ('你好', '打招呼 + 一句提示能干什么'),
]


def route(text):
    for kw, act in rules:
        if kw in text:
            return '%s → %s' % (kw, act)
    return '(没命中规则)默认回一句「发「帮助」看能做什么」'


for t in ('我要报修', '给个报价', '转人工', '你好啊', '今天天气不错'):
    print('    %-12s %s' % (t, route(t)))
print('  关键词匹配严格按顺序,所以「报修」要写在「报」前面,不然更宽的关键词会先截走。')
print('  关键词做客服的问题是会漏:用户说「坏了」「不亮了」,你写十个关键词也盖不全。')
print('  真实产品会用大模型做意图识别,但关键词这一层永远留着当兜底——它至少不会胡说八道。')
print()
print('  ② 报修流程:一步步问')
print('  ' + '-' * 68)
flow = ['请问是哪个设备?(发设备编号)',
        '大概什么问题?(可以发图片)',
        '已记录,工单号 WO-20261102-007']
st = {'i': 0}


def report(text):
    i = st['i']
    if i >= len(flow):
        st['i'] = 0
        return '(流程已结束,重新开始)'
    st['i'] = i + 1
    return flow[i]


print('    用户:我要报修')
for t in ('P-102', '灯不亮了', '好'):
    print('    用户:%-10s 机器人:%s' % (t, report(t)))
print('  这就是「有限状态机」:一共几个状态、每个状态问什么、答完跳到哪。画成图就是几个圈加几条箭头。')
print('  比起把判断写成一层套一层的 if,用一张表来描述状态,改流程时只改表,不动代码。')
print()
print('  ③ 图文选单:一张图加几块区域')
print('  ' + '-' * 68)
rich = {
    'size': {'width': 2500, 'height': 1686},
    'selected': True,
    'name': '主选单',
    'chatBarText': '点这里看功能',
    'areas': [
        {'bounds': {'x': 0, 'y': 0, 'width': 1250, 'height': 843},
         'action': {'type': 'message', 'text': '报修'}},
        {'bounds': {'x': 1250, 'y': 0, 'width': 1250, 'height': 843},
         'action': {'type': 'message', 'text': '报价'}},
        {'bounds': {'x': 0, 'y': 843, 'width': 2500, 'height': 843},
         'action': {'type': 'uri', 'uri': 'https://example.com/help'}},
    ],
}
for ln in json.dumps(rich, ensure_ascii=False, indent=2).split('\n')[:16]:
    print('  ' + ln)
print('  一张图配几个矩形区域,点哪块触发哪个动作。')
print('  坐标是按图片的原始像素算的,跟手机屏幕大小无关——所以图片尺寸写错,热点就会整体偏移。')
print('  action 有三种常用类型:message(假装用户发了句话)、uri(打开网址)、')
print('  postback(发一个带数据的回调,适合「选日期」这种)。')
print()
print('  ④ 模板消息:让用户点,不用打字')
print('  ' + '-' * 68)
tpl = {
    'type': 'template',
    'altText': '请选择要报修的设备',
    'template': {
        'type': 'buttons',
        'title': '选择设备',
        'text': '点一下就行',
        'actions': [
            {'type': 'postback', 'label': '车间照明', 'data': 'dev=light'},
            {'type': 'postback', 'label': '门禁', 'data': 'dev=door'},
        ],
    },
}
for ln in json.dumps(tpl, ensure_ascii=False, indent=2).split('\n')[:14]:
    print('  ' + ln)
print('  用户点了按钮,你不会收到一条「文字消息」,而是收到 postback 事件,数据在 ev.postback.data 里,')
print('  就是上面那个 dev=light。')
print('  按钮的好处是「不用猜用户会怎么打字」。让他选,比让他输入可靠得多。')
print()
print('  ⑤ 一个机器人该有的五件事')
print('  ' + '-' * 68)
print()
print('| 能力 | 本课对应 | 缺了会怎样 |')
print('|---|---|---|')
for r in (('验签', '第 13-1 节', '谁都能伪造消息,等于没有鉴权'),
          ('分流', '关键词 + 状态机', '用户说什么都得不到有用回复'),
          ('记住你是谁', 'userId 存状态', '每次对话都从头问一遍'),
          ('出错兜底', 'try/except 回一句「稍后再试」', '报错不回,用户以为你死了'),
          ('日志', '每条事件存一份', '用户投诉时完全查不到')):
    print('| %s | %s | %s |' % r)
print()
print('  第四条最容易漏。')
print('  机器人抛异常时 LINE 不会替你回话,用户那边就是「发了没反应」,比回一句「系统忙」糟得多。')
print('  第五条是运维的命根子。')
print('  上线之后最常问的三个问题——他发的是什么、我回的是什么、为什么没回——全靠日志回答。')

实跑输出:

--------------------------------------------------------------------
客服路由与图文选单
--------------------------------------------------------------------

  ① 关键词路由:先分流,再处理
  --------------------------------------------------------------------
    我要报修         报修 → 进入报修流程,先问设备编号
    给个报价         报价 → 发一份价目表图片
    转人工          人工 → 转人工,记录工单
    你好啊          你好 → 打招呼 + 一句提示能干什么
    今天天气不错       (没命中规则)默认回一句「发「帮助」看能做什么」
  关键词匹配严格按顺序,所以「报修」要写在「报」前面,不然更宽的关键词会先截走。
  关键词做客服的问题是会漏:用户说「坏了」「不亮了」,你写十个关键词也盖不全。
  真实产品会用大模型做意图识别,但关键词这一层永远留着当兜底——它至少不会胡说八道。

  ② 报修流程:一步步问
  --------------------------------------------------------------------
    用户:我要报修
    用户:P-102      机器人:请问是哪个设备?(发设备编号)
    用户:灯不亮了       机器人:大概什么问题?(可以发图片)
    用户:好          机器人:已记录,工单号 WO-20261102-007
  这就是「有限状态机」:一共几个状态、每个状态问什么、答完跳到哪。画成图就是几个圈加几条箭头。
  比起把判断写成一层套一层的 if,用一张表来描述状态,改流程时只改表,不动代码。

  ③ 图文选单:一张图加几块区域
  --------------------------------------------------------------------
  {
    "size": {
      "width": 2500,
      "height": 1686
    },
    "selected": true,
    "name": "主选单",
    "chatBarText": "点这里看功能",
    "areas": [
      {
        "bounds": {
          "x": 0,
          "y": 0,
          "width": 1250,
          "height": 843
        },
  一张图配几个矩形区域,点哪块触发哪个动作。
  坐标是按图片的原始像素算的,跟手机屏幕大小无关——所以图片尺寸写错,热点就会整体偏移。
  action 有三种常用类型:message(假装用户发了句话)、uri(打开网址)、
  postback(发一个带数据的回调,适合「选日期」这种)。

  ④ 模板消息:让用户点,不用打字
  --------------------------------------------------------------------
  {
    "type": "template",
    "altText": "请选择要报修的设备",
    "template": {
      "type": "buttons",
      "title": "选择设备",
      "text": "点一下就行",
      "actions": [
        {
          "type": "postback",
          "label": "车间照明",
          "data": "dev=light"
        },
        {
  用户点了按钮,你不会收到一条「文字消息」,而是收到 postback 事件,数据在 ev.postback.data 里,
  就是上面那个 dev=light。
  按钮的好处是「不用猜用户会怎么打字」。让他选,比让他输入可靠得多。

  ⑤ 一个机器人该有的五件事
  --------------------------------------------------------------------

| 能力 | 本课对应 | 缺了会怎样 |
|---|---|---|
| 验签 | 第 13-1 节 | 谁都能伪造消息,等于没有鉴权 |
| 分流 | 关键词 + 状态机 | 用户说什么都得不到有用回复 |
| 记住你是谁 | userId 存状态 | 每次对话都从头问一遍 |
| 出错兜底 | try/except 回一句「稍后再试」 | 报错不回,用户以为你死了 |
| 日志 | 每条事件存一份 | 用户投诉时完全查不到 |

  第四条最容易漏。
  机器人抛异常时 LINE 不会替你回话,用户那边就是「发了没反应」,比回一句「系统忙」糟得多。
  第五条是运维的命根子。
  上线之后最常问的三个问题——他发的是什么、我回的是什么、为什么没回——全靠日志回答。

第一段第五句「今天天气不错」走的是兜底,这一条比前四条都重要——没命中时什么都不回, 是最伤体验的写法。 第二段四行里,P-102 那一行是「等设备号」状态收到的答案,「灯不亮了」是「等问题描述」收到的, 最后那个工单号是流程结束时生成的。 第三段那个 2500 乘 1686 不是随便写的,它就是 imgur 那张图的像素尺寸。 尺寸一换,三块 bounds 全部要重算。 第四段那个 dev=light 就是 postback 事件里你会收到的数据。 真机上它出现在 ev.postback.data,不是 ev.message.text。 第五段那张表是本篇的总结。 注意「出错兜底」和「日志」这两行——它们不影响功能演示,但决定这个机器人能不能上线。

30 秒实验

  1. 在演示第一段的 rules 最前面加一条 报,再跑一次,看「我要报修」被哪条规则截走。
  2. 给演示第二段的 flow 加一步「方便什么时候上门」,看输出多出哪一行。
  3. 把演示第三段第一块 bounds 的 width 从 1250 改成 1000,想想点第一块右边的空白会怎样。

常见误区

  1. 「关键词写得够多就行。」写得再多也盖不全用户的说法。 关键词当兜底,意图识别交给模型,两层配合才稳。
  2. 「按钮点下去也是发一条文字消息。」不是。 postback 是另一类事件,数据在 postback.data,走的是另一条处理路径。
  3. 「状态机就是一堆 if。」能跑,但加两个状态就没人敢改了。用一张表描述状态,改流程只改表。
自测三问(点开对答案) 1. 为什么关键词规则要「从窄到宽」排,而不是按重要性排? —— 因为匹配是顺序走的,第一条命中就返回。 `报` 比 `报修` 宽,放在前面会把「我要报修」全截走,后面的报修流程永远进不去。 所以窄的、具体的规则必须排在宽的、笼统的前面。 2. 用户点图文选单和点按钮,你收到的事件有什么区别? —— 选单的 `action` 如果是 `message`,你收到的就是普通文字消息,等于用户打了那几个字; 按钮一般是 `postback`,你收到的是 postback 事件,数据在 `postback.data` 里, 要单独写一条分支去处理。 3. 机器人处理时抛异常,LINE 会替你把错误告诉用户吗? —— 不会。LINE 只关心你有没有返回 200,用户那边就是「发了没反应」。 所以必须自己用 `try/except` 包住,出错回一句「稍后再试」, 同时把异常记进日志——不然你连哪里错了都不知道。

第 13 章结束。下一篇进第 14 章:影像处理,从一个像素三个数开始。

相关笔记