素材来自启元机器人在 AtomGit 的官方开源仓库
primebot/didi_taxi_agent(PrimeAgent 滴滴打车技能),代码为官方原版。
文章目录
-
- 这篇文章适合谁
- 读懂这篇之前:先补两个前置概念
- 先看整体:一次叫车的完整链路
- 设计一:收信息不厌其烦,花订单寸不让
- 设计二:确认要"复述",不能只问"确定吗"
- 设计三:机器人说完才算说完
- [仓库导览:1627 行代码都装了什么](#仓库导览:1627 行代码都装了什么)
- 给技能开发者的三条启示
让机器人帮你叫车,听起来是个挺酷的功能。但真要做,你会先撞上一连串以前写 App 从没想过的问题:
用户说"我在人民广场附近",ASR 转出来可能是"人民广场",也可能带一堆语气词;用户改主意了,说"算了不叫了",机器人得听懂;最要命的是最后一步------这是要花钱的真实订单,机器人凭什么替你下单?
启元官方开源的 didi_taxi_agent 技能(约 1600 行)把这一整套问题都处理了。这篇文章不逐行讲代码,只拆它最值得学的三个设计:信息收集、明确确认、说完才继续。
这篇文章适合谁
先说清楚适用对象,省得你读一半发现不对路:
- 适合:想给启元 Q1/T1 写技能、但还没动过手的开发者------这篇拆的官方样本,就是"第一个技能该长什么样"的完整答案;写过简单技能(比如照官方模板改个天气查询),准备挑战带真实外部服务的复杂技能的人;对语音交互设计感兴趣的产品和算法同学------确认闭环、播报同步这些经验不绑 SDK,换个平台照样用;
- 不太适合:想找"怎么从零装 SDK、连机器人"的------那是上一篇 CLI 教程的事,本文只讲技能层的代码;想学滴滴 API 怎么申请的------本文拆的是技能与平台对接的设计,不涉及商业接入流程;
- 需要的基础:会 Python,知道"调用一个函数"和"订阅一条消息"的区别就行。ROS 2 和机器人硬件相关的概念,正文会在用到时随讲随补(技能是什么、双板架构怎么控硬件,都写在第一节)。
读法建议:第一遍顺着读,重点看三个设计;想动手写技能时,再回来对着"仓库导览"那一节的目录结构逐文件精读。
读懂这篇之前:先补两个前置概念
这篇要拆的是官方技能的源码,但直接从"确认闭环"讲起会有两个悬空的概念。先花两分钟补齐,后面每一段代码你都知道它"站在哪里"。
技能(Skill)到底是什么?
一句话:在启元机器人中,技能就是机器人上的一个普通 Python 程序,随叫随到、用完退出。
它不是预装在机器人固件里的,而是"装"进去的一个小软件包------就像手机上的 App。看官方仓库的目录结构,一个技能只有三样东西:
didi_taxi_agent/
├── main.py # 入口:技能被唤起时执行的第一个文件
├── cfg/pkg.yaml # 身份证:名字、版本、触发描述
└── src/didi_taxi_agent/ # 业务代码:对话、下单、控制机器人
pkg.yaml 里的 description 字段写着一串触发词("滴滴打车、叫车、帮我打车、帮我叫车"),机器人听到这些话就会把技能拉起来;main.py 负责接上运行环境(配环境变量、拉起 Python 进程),然后进入真正的对话流程。
技能活着的时候,它在机器人上就是一个普通的 ROS 2 节点进程------订阅话题、调用服务,和任何其他节点没有区别。这也是为什么它可以被社区开发者写出来、打包、分发:它用的就是官方开放的标准接口。
机器人的"大脑"怎么控制自己的硬件?
再看这行代码的来历:robot.play_tts("请告诉我目的地")。一个 Python 函数调用,凭什么能让机器人发声?
因为启元 Q1 是双板架构 :大脑板跑语音对话(PrimeAgent:ASR 识别、意图理解、TTS 合成),运控板跑运动控制(行走、关节、平衡)。两块板加上你的技能,全都是 ROS 2 网络里的节点,互相之间只通过标准服务调用说话:
PlayTts服务:把一段文字交给语音管线播报(这是本文的主角);SetMcAction服务:向运动控制申请一个动作(文末派单成功后机器人做的开心动作就是它);SetAgentPropertiesRequest服务:读取或切换 Agent 的工作模式(only_asr就是这么切出来的);/aima/agent/asr/final话题:语音识别结果持续往回传,技能订阅它来"听"用户的回答。
整条链路画出来是这样:

记住两个事实就够读代码了:服务调用是"申请" ------发出去之后硬件不一定立刻执行,状态要通过话题和响应慢慢确认(这正是本文确认闭环的物理基础);技能自己不直接碰硬件------它只跟这些服务打交道,安全边界由机器人侧守着。
补完这两块,回头看流程就顺畅了。环境搭建的细节(网线直连、开发者模式、SDK 编译)在上一篇 CLI 教程里讲过,这里不重复。
先看整体:一次叫车的完整链路
技能被唤起后,整个流程是一条有多个出口的链:

对应的实现骨架在 app.py 的 main() 里,主干就是这么几步:
python
previous = robot.get_mode() or "normal" # 1. 记住机器人原来的模式
if not robot.set_mode("only_asr"): # 2. 切到"只听不说"模式
raise RuntimeError("无法切换 Agent 到 only_asr")
origin = ask(robot, asr, "好的。请告诉我从哪里上车?你也可以说当前位置。")
# ...终点同理...
robot.speak(f"从{origin}到{destination},确认叫车吗?请明确说确认叫车,或者说取消。")
answer = wait_text(asr)
if not is_confirm(answer): # 3. 不明确确认 = 不下单
robot.speak("已取消,没有创建订单。")
return
result = business.run({"origin": origin, "destination": destination, ...})
robot.speak(result.get("speech") or "订单已经提交...")
有意思的是第 1、2 步和最后的还原:技能不默认机器人"归自己用",而是先查当前模式、存下来,退场时恢复。多个技能共存时,谁都不留烂摊子------这是很多二开代码会漏掉的细节。
设计一:收信息不厌其烦,花订单寸不让
看 dialog.py 里的关键词定义:
python
HERE_WORDS = {"这里", "当前位置", "我这里", "现在的位置"}
CANCEL_WORDS = ("取消", "不叫了", "不要", "别叫", "退出", "结束")
CONFIRM_WORDS = ("确认叫车", "确认下单", "确定叫车", "现在叫车")
三组词的宽严程度完全不同,这个差异是刻意设计出来的:
- 取消词很宽------"不要""别叫"这些随口一说的话都算数,宁可错杀订单也不能误下单;
- 确认词很窄------必须是"确认叫车"这个固定短语,你说"行""好的""嗯"都不算数。
而且 is_confirm 里还藏了一个反向排除:
python
def is_confirm(text):
compact = re.sub(r"\s+", "", str(text or ""))
return not is_cancel(compact) and any(word in compact for word in CONFIRM_WORDS)
"不要确认叫车"这句话同时包含取消词和确认词------先判取消,再判确认,歧义一律往安全的一边倒。语音交互里 ASR 会犯错,用户的口语又千奇百怪,这种"拿不准就按不花钱处理"的取舍,比任何花哨的 NLP 都重要。
上车点的处理也是同样的思路:用户说"当前位置",机器人不会自作聪明直接用,而是报出具体地址再要一次确认------"当前位置将使用新中源大楼作为上车点,请说确认位置,或者说取消"。模糊表达宁可多问一轮。
设计二:确认要"复述",不能只问"确定吗"
注意确认话术的措辞:
python
robot.speak(f"从{origin}到{destination},确认叫车吗?请明确说确认叫车,或者说取消。")
先把起点终点复述一遍,再请用户确认。这不是啰嗦------ASR 听错一个字("人民广场"听成"人民光场")太常见了,复述等于把机器听到的内容亮出来给用户核对。用户听到的不对,自然会纠正,错误就被拦在了下单之前。
这个模式可以直接抄到任何"会花钱/会动真格"的技能里:转账、下单、开锁、启动设备......凡是不可逆操作,话术三件套:复述内容 + 指定确认短语 + 给出取消出口。
设计三:机器人说完才算说完
这是全文技术含量最高的一处。看 robot.py 的 speak():
python
def speak(self, text):
trace = f"didi-taxi-{time.time_ns()}" # 每句播报一个唯一 ID
req = PlayTts.Request()
req.tts_req.text = text
req.tts_req.priority_level = TtsPriorityLevel(value=TtsPriorityLevel.INTERACTION_L6)
req.tts_req.trace_id = trace
req.tts_req.is_interrupted = True
self._call(self.play_tts, req)
# 等 /interaction/tts_status 话题回传这一句的结束事件
deadline = time.monotonic() + max(5, min(35, len(text) * .3 + 4))
while trace not in self.terminals and time.monotonic() < deadline:
time.sleep(.05)

PlayTts 这个服务调用是"异步"语义:请求返回只代表排队成功,不代表机器人念完了。如果代码发完请求就接着问下一个问题,就会出现机器人话还没说完、用户抢答、答案被当成了对上一句的回应------乱套。
官方的做法是给每句播报挂一个 trace_id,然后订阅 /interaction/tts_status 话题,收到 END/STOP/ERROR 状态后按 ID 匹配,确认这句真的说完了,才继续走流程 。等待时长还按文本长度动态算了上限(len(text) * 0.3 + 4 秒,夹在 5 到 35 秒之间),防止某句播报状态丢了就把流程卡死。
另一个细节:ASR 订阅端用 event_id 做了去重(一个 128 长度的环形去重集合),同一个识别结果不会被处理两遍。语音链路上"同一条消息到两次"不是bug 才有的现象,是常态,去重要在入口做。
仓库导览:1627 行代码都装了什么
前面拆的代码只是冰山一角。整仓库用 wc -l 数一遍,各文件的真实体量是:
text
89 app.py # 叫车主流程
33 asr.py # 听:ASR 订阅
90 robot.py # 说:TTS / 模式 / 动作
32 dialog.py # 对话规则(确认词、取消词)
921 business.py # 滴滴业务主体(最大头)
461 didi_ride.py # 滴滴开放平台 API 客户端
1 __init__.py
──────
1627 行
按职责分,其实就四层:

| 文件 | 角色 | 关键内容 |
|---|---|---|
app.py |
流程编排 | main() 主链路、ask() 问答、模式保存/切换/还原、异常兜底 |
asr.py |
听 | 订阅 /aima/agent/asr/final,is_final 过滤 + event_id 去重 |
robot.py |
说 / 控 | speak() 播报闭环、get_mode()/set_mode()、set_volume()、happy() |
dialog.py |
对话规则 | clean_place / is_here / is_cancel / is_confirm,自带 self_check |
business.py |
滴滴业务主体 | run() 下单入口、订单状态跟踪线程、屏幕视频渲染、行程信息提取 |
didi_ride.py |
滴滴 API 客户端 | 密钥加载、POI 地点搜索、下单、query_order_until_dispatch 轮询派单 |
逐文件读完,有三个设计值得单独说:
一、屏幕也是出口,不止语音。 business.py 921 行里,相当一部分在渲染屏幕视频:_make_didi_screen_video / _render_didi_screen_image / _play_didi_screen_video_async------下单后机器人的屏幕会播放"正在等车/派单成功"的画面。语音交互不是唯一 UI,长时间等待的状态靠屏幕补,这是很多技能开发者想不到的一层。
二、下单之后,跟踪是后台线程。 _start_didi_order_tracker 起了一个独立线程轮询订单状态,不阻塞主流程------机器人可以先把"正在为你叫车"播出来,派单结果出来再补播。语音交互里"立刻有响应、结果慢慢跟"是标准姿势。
三、密钥不打包。 didi_ride.py 的 load_api_key 读的是机器人上的运行时文件(/home/run/.../didi_api_key.txt),README 里专门写明"密钥不会打入包内"。开源仓库里没有任何秘密,别人 clone 下来配好自己的密钥就能跑------这是所有涉及第三方服务的技能都该遵守的纪律。
想通读源码的话,推荐顺序:app.py → dialog.py → robot.py → asr.py → business.py → didi_ride.py,从流程到细节、从"听说"到底层,每一层都比上一层更靠近具体业务。
给技能开发者的三条启示
把这三层拆完,可以提炼出三句可迁移的经验:
- 花钱的操作,确认词窄、取消词宽,歧义一律不执行。误触发一次真实订单的代价,远大于用户多说一句"确认叫车"。
- 不可逆操作前必复述。让用户核对机器"听到的",而不是机器猜用户"想说的"。
- 语音交互里,"说完"和"发完"是两回事。用 trace_id + 状态话题把播报变成同步语义,流程才不会跑在用户前头。
官方技能仓库的代码量都不大(打车技能核心逻辑也就一千来行),但每一行都踩在真实交互的坑位上。想上手启元二开的话,与其凭感觉造轮子,不如先把这几个官方样本拆一遍。
本文涉及的仓库都可以在 AtomGit 的 primebot仓库 组织下找到。
如果你也想给机器人写技能,启元机器人开发者社区 https://dev.primebot.com/ 有完整的 SDK 文档和开发者论坛,跑通的技能还能上架 PrimeHub 分享给更多人。