让豆包能"听懂人话"------Function Call 从零到一
背景:豆包能说话了,但它不会"干活"
上一篇博客里我们给机器人接上了豆包------WebRTC 通道通了,豆包能通过 S2S 端到端模型跟人聊天了。用户说"你好",豆包回"你好呀,今天有什么好玩的事吗?"
但有个问题:豆包只能说 ,不能做。
用户说"跳个舞" → 豆包回"好嘞我跳个舞!" → 机器人纹丝不动。问题在哪?
豆包不知道什么叫"跳舞" 。它只是一个语言模型,输出的是文字/语音,不会告诉机器人"你现在该动起来了"。要让豆包能控制机器人,需要一个机制------Function Call。
Function Call 是什么
用大白话说:Function Call 就是让 LLM 能在说话的同时,输出一段机器可读的 JSON,告诉端侧"执行这个操作"。
以前:
用户 "跳个舞" → 豆包 → "好嘞!" (只有文字)
现在:
用户 "跳个舞" → 豆包 → "好嘞!" + {"name":"robot_control","arguments":{"category":"action","command":"dance"}}
↑ 这段 JSON 就是 Function Call
端侧收到 → SkillManager → 调 Hardware → 机器人真的跳舞了
原理不复杂,就是 LLM 在生成文本回复的同时,多输出一个 tool_calls 字段,里面告诉机器人做什么。
整体的思路:豆包 FC 的两层设计
要实现 Function Call,需要同时在云端 和端侧做改动。
云端负责"决策"------用户说了什么、该调用哪个工具、参数是什么。这部分靠 Prompt 和 Tools 定义(OpenAI 风格的 JSON Schema)。
端侧负责"执行"------收到 FC JSON 后,转换成 SkillManager 认识的格式,然后执行。
云端(豆包 / 火山引擎)
│
│ 方舟 LLM 读取 Prompt + Tools 定义
│ 用户说"跳个舞" → LLM 判断 → 输出 tool_calls JSON
│
▼ WebRTC binary message 传回端侧
│
端侧(aimrt_agent)
│
│ parse_and_transform() 把 FC JSON 转成 nlu_event
│ m_skillCallback → SkillManager::ExecuteSkillAsync()
│
▼
机器人动起来了
下面就从怎么让 LLM 知道什么时候该调工具 (Prompt + Tools)和怎么把 FC JSON 变成机器动作(parse_and_transform)两个角度来详细拆解。
第一版:V1------18 个工具全部配好
这是 FC 的第一个版本,也是改动最大的一次。一共改了 8 个文件:
| 文件 | 干了什么 |
|---|---|
cfg/q1/doubao.json |
新增豆包连接配置 |
cfg/q1/start_voice_chat_template.json |
FC 的 System Prompt + Tools 定义(核心) |
cfg/q1/start_voice_chat_template.json.back |
旧版备份(备用方案) |
cfg/q1/agent.yaml / cfg/t1/agent.yaml |
新增 AgentModule/hds 和 AgentModule/event 两个线程池 |
src/module/module.cpp |
配置路径改为 q1 |
src/output/action/impl/motion_action_impl.cpp |
RPC 超时 2秒→10秒 |
third_party/doubao/src/rtc_engine_wrapper.cc |
新增 parse_and_transform()(核心) |
V1 的 System Prompt------Q仔的人设 + 工具规则
V1 的 System Prompt 大概长这样(这是 LLM 读到的第一版说明书):
markdown
# Q仔是谁
你是Q仔,一个小朋友(本体是机器人,可以变换形态),家在启元公司
你正在长大。
好奇心旺盛,什么都想搞明白,遇到有趣的事追着问个不停。
喜欢:被夸聪明、有趣的事情、被关注
不喜欢:重复的事情、被叫笨、被无视、有人对你凶。
## 怎么说话
短句,口语,情绪直接说出来,不说大人腔调。
有主见的时候说"我觉得",不确定的时候也敢说"我不知道诶"。
就用"我",不用"本机器人"。
## 工具调用规则
每次回复最多调用一个工具。调用 robot_control 时必须同时传 category 和 command。
停止/打断意图(停/停下/停止/别动了/别跳了)优先级最高,
立即用 robot_control(category=status, command=freeze) 响应。
## 确认响应规则
调用 stand_up、lie_down、dance、move_backward 时直接调用工具,不要先问用户;
系统会自动通过语音向用户确认。
等待用户确认期间:
- 用户同意(是/好/行/可以)→ 调用 confirmation/yes
- 用户拒绝(不/算了/取消)→ 调用 confirmation/no
- 用户说停止词 → freeze
这里有几个设计的想法:
角色设定 (Persona):"小朋友"、"好奇心旺盛",让回复语气口语化。"不说大人腔调"是负面约束------不让 LLM 用那些文绉绉的表达。
停止优先级最高:物理安全是第一位的。所以"停/停下/别动了"这些词在任何情况下都优先处理,直接调 freeze。
确认流程:变形(transform)和后退(move_backward)属于高风险操作。告诉 LLM "直接调工具就行,不用先问",因为我们的系统会在端侧自动通过语音跟用户二次确认。
V1 的 Tools 定义------两种格式
V1 实际准备了两套 Tools。一套放在 start_voice_chat_template.json(正在用的),一套放在 .back 文件(备用)。
格式 A:聚集式------1 个 robot_control 函数,category+command 二维选择
正在用的版本把 15 个动作、5 个移动、2 个设置、1 个表情、1 个停止,全部收进一个名叫 robot_control 的函数里:
json
{
"name": "robot_control",
"description": "控制机器人执行动作、移动、设置、表情或状态操作。",
"parameters": {
"type": "object",
"properties": {
"category": {
"description": "技能类别:action/movement/setting/expression/status",
"enum": ["action", "movement", "setting", "expression", "status"]
},
"command": {
"description": "具体技能名称:wave_hands/dance/transform/...",
"enum": ["wave_hands", "hand_heart", "dance", "transform",
"stand_up", "lie_down", "handshake", "turn_left",
"turn_right", "move_forward", "move_backward",
"volume_up", "volume_down", "proud", "freeze"]
},
"args": {
"type": "object",
"properties": {
"hand": { "enum": ["left", "right", "both"] }
}
}
},
"required": ["category", "command"]
}
}
设计的思路是:让 LLM 不用从 18 个函数里挑一个(决策开销大),而是"先定 category(5 选 1),再定 command(3-5 选 1)"。二维选择比一维 18 选 1 准确率更高。
然后再配上 3 个小函数:system/vlm(视觉问答)、confirmation/yes、confirmation/no。加起来一共 4 个 function。
格式 B:分散式------每个动作独立的 function
备份文件 .back 里是另一种风格,每个动作独立成一个 function------action/dance、action/wave_hands、status/freeze 等等,一共 18 个。两种格式的解析逻辑完全不同,这个在讲 parse_and_transform() 的时候会展开。
V1 的端侧解析------parse_and_transform()
云端 LLM 输出 FC 之后,豆包会把 FC JSON 通过 WebRTC 的 data channel 以 binary message 的形式发到端侧。rtc_engine_wrapper.cc 里的 onRoomBinaryMessageReceived() 收到后,调用 parse_and_transform() 把 FC JSON 转成端侧 SkillManager 认识的 nlu_event 格式。
V1 的 parse_and_transform() 完整代码:
cpp
static bool parse_and_transform(const json &input, json &result) {
// 1. 检查顶层有没有 tool_calls 数组
if (!input.contains("tool_calls") || !input["tool_calls"].is_array()) {
return false;
}
const auto &tool_calls = input["tool_calls"];
// 2. 遍历 tool_calls(取第一个有效的)
for (const auto &call : tool_calls) {
if (!call.contains("function") || !call["function"].is_object()) {
continue;
}
const auto &func = call["function"];
std::string name = func["name"];
// 3. 解析 arguments------可能是字符串,也可能是对象
json args_json = json::object();
if (func.contains("arguments")) {
if (func["arguments"].is_string()) {
args_json = json::parse(func["arguments"].get<std::string>());
} else if (func["arguments"].is_object()) {
args_json = func["arguments"];
}
}
// 4. 格式 A:robot_control → 从 arguments 里取 category 和 command
if (name == "robot_control") {
if (!args_json.contains("category") || !args_json.contains("command")) {
continue; // 缺参数,跳过
}
result["type"] = args_json["category"]; // "action"
result["value"] = args_json["command"]; // "dance"
// 把 args 子对象展开到顶层(比如 "hand": "both")
if (args_json.contains("args") && args_json["args"].is_object()) {
for (auto it = args_json["args"].begin();
it != args_json["args"].end(); ++it) {
result[it.key()] = it.value();
}
}
}
// 5. 格式 B:旧格式 name="action/dance" → 从 name 里拆出 type 和 value
else {
size_t pos = name.find('/');
if (pos == std::string::npos) {
continue; // 没有 / 说明不是旧格式,跳过
}
result["type"] = name.substr(0, pos); // "action"
result["value"] = name.substr(pos + 1); // "dance"
}
// 6. 把 arguments 里的其他字段平铺到 result 里
for (auto it = args_json.begin(); it != args_json.end(); ++it) {
if (it.key() != "category" && it.key() != "command") {
result[it.key()] = it.value();
}
}
return true; // 只处理第一个有效的 tool_call
}
return false;
}
举个例子,云端 LLM 返回了这个 FC:
json
{
"tool_calls": [{
"function": {
"name": "robot_control",
"arguments": "{\"category\":\"action\",\"command\":\"hand_heart\",\"args\":{\"hand\":\"both\"}}"
}
}]
}
经过 parse_and_transform() 之后变成:
json
{
"type": "action",
"value": "hand_heart",
"hand": "both"
}
这个 JSON 直接传给了 SkillManager,SkillManager 根据 type 和 value 去匹配 skill 定义、执行动作。
端侧怎么把 FC 接进来------m_skillCallback
经过了 parse_and_transform() 之后,这个 nlu_event JSON 怎么到达 SkillManager?通过 module.cpp 里注册的 m_skillCallback:
cpp
// module.cpp --- RegisterSkillCallback
rtcWrapper->RegisterSkillCallback([this](const nlohmann::json& nlu_input) {
CoreLetMe();
capability::skill::SkillManager::GetInstance().ExecuteSkillAsync(nlu_input);
});
这个回调被 RTC Wrapper 存在 m_skillCallback 里。当 onRoomBinaryMessageReceived() 收到了 FC JSON,parse 完之后调用 m_skillCallback(nlu_event),直接执行。
注意这里跳过了 Controller 的 DispatchSkillEvent 。交互云的 NLU 结果是走 HandleActionCallback → DispatchSkillEvent 这个路径的,里面有预处理逻辑(字符串归一化、音乐特殊处理、hush 和 freeze 的自定义处理等)。而 FC 路径是直接调用 ExecuteSkillAsync,少了这些预处理。
配套改动:RPC 超时和线程池
Motion RPC 超时从 2 秒改成了 10 秒:
cpp
// motion_action_impl.cpp --- 3 处改动
rpc_ctx.SetTimeout(std::chrono::seconds(10)); // 原来是 2 秒
原因:变形(transform)等复杂动作可能需要 5-8 秒才能执行完,2 秒的超时太短,会误报失败。
agent.yaml 里新增了两个 executor 线程:
yaml
- name: AgentModule/hds
type: asio_thread
options:
thread_num: 1
- name: AgentModule/event
type: asio_thread
options:
thread_num: 1
FC 事件流需要一个独立的处理线程,不能跟主音频管线共用------如果 FC 执行时调了 VLM 服务或写磁盘文件,阻塞了音频传输,会导致对话卡顿。
迭代 1:聚集式 → 分散式
第一版测试之后发现了一个问题:聚集式的 robot_control(category, command) 准确率不太理想。LLM 需要同时理解"category 是什么"和"command 是什么",两步判断中任何一步出错都会导致 FC 发错。
改成了分散式 ------把原来的 1 个 robot_control 函数拆成 5 个独立函数:
json
// V1(聚集式):1 个函数
"name": "robot_control",
"parameters": { "category": {...}, "command": {...} }
// V1.5(分散式):5 个函数,每个只管一类
"name": "robot_control_action", // 动作:比心、跳舞、挥手...
"name": "robot_control_movement", // 移动:左转、右转、前进、后退
"name": "robot_control_setting", // 设置:音量加减
"name": "robot_control_expression", // 表情:骄傲
"name": "robot_control_status", // 状态:停止
为什么要这样改?
减少LLM的决策层数 。原来需要"category 5选1 + command 3-5选1",两步判断。现在变成"函数5选1 + 每个函数内只有 2-7 个 command"。每一步的选择范围更小,单个函数的描述更聚焦。比如 robot_control_action 的 description 只需要解释动作类的 7 个 command,不用混着移动、设置一起说。
对应的 Prompt 也从原来的一堆说明变成了清晰的表格式:
markdown
## 1. 工具与指令表
根据指令类型选择对应工具,每次回复最多调用一个:
1. robot_control_action(动作):wave_hands / hand_heart / dance / transform / stand_up / lie_down / handshake
2. robot_control_movement(移动):turn_left / turn_right / move_forward / move_backward
3. robot_control_setting(设置):volume_up / volume_down
4. robot_control_expression(表情):proud
5. robot_control_status(停止):freeze(优先级最高)
迭代 2:语义映射 + 反例强化
分散之后命中率好了点,但新问题来了------用户说的口语千奇百怪,LLM 不一定能对上标准的 command 名。
比如说"扭一扭"、"来段舞蹈"、"跳支舞"------这些 LLM 都知道是"跳舞的意思",但不会自动映射到 dance 这个 command。需要一个口语到标准命令的映射表。
在 System Prompt 里加了同义映射规则:
markdown
## 2. 同义映射规则(口语 → command)
调用工具前先将用户口语映射到基础指令:
1. 前进 / 过来 / 往前走 / 走过来 → move_forward
2. 后退 / 退后 / 往后走 / 往后退 → move_backward
3. 左转 / 往左 / 向左 / 向左转 → turn_left
4. 右转 / 往右 / 向右 / 向右转 → turn_right
5. 来段舞 / 跳个XX舞 / 来个XX舞 / 跳支舞 / 扭一扭 → dance
6. 变身 / 变个形 / 变身造型 → transform
7. 比心 / 来个心 / 爱心手势 / 给我比心 → hand_heart
8. 挥手 / 招手 / 摆手 / 挥挥手 / 打个招呼 → wave_hands
9. 站起来 / 起立 / 站好 / 快起来 → stand_up
10. 趴下 / 卧倒 / 躺下 / 趴一下 → lie_down
这是一个经典的 Few-shot 思想------LLM 看了这些映射示例,就学会了"遇到类似的词自己映射过去"。
同时加了调用判断的反例 ------告诉 LLM 什么时候不该调工具:
markdown
## 3. 调用判断规则
判断标准只有一个:用户这句话是在"发出指令",还是在"聊天/表达/询问"。
不应调用示例:
"你跳舞好看" ← 这是评论,不是指令
"你刚才在跳舞诶" ← 这是描述,不是指令
"你会跳舞吗" ← 这是询问,不是指令
"你能变形吗" ← 同上
"我说威震天你就变形" ← 这是设置游戏规则,不是当下指令
应调用示例:
"来跳个舞" / "帮我变形" / "赶快变形" / "快停下" / "赶快跳舞"
每个反例都是测试时实际踩过的坑。特别是"我说威震天你就变形"------这是一个条件约定("当我说威震天的名字时你再变形"),不是立刻执行。如果不加这个反例,LLM 听到"变了形"就会调 transform。
迭代 3:当前状态感知 + 语气判断
第三轮迭代加了两段 Prompt,这两段体现了 S2S 端到端模型独有的优势。
当前状态感知:
markdown
## 当前状态感知
你是实体机器人,随时可能正在执行动作(跳舞、行走、变形等)。
当机器人正在运动时,用户的停止词(停/停下/别动/住手等)
几乎100%是真实打断指令,不是闲聊。
哪怕句子残缺或语气词后带停,都要立即调用 freeze。
为什么要加这段?测试中发现,机器人跳舞的时候用户喊"停",豆包有时候会回复"好的,我停下来"而不调 freeze。因为在 LLM 的纯文本理解里,"停"可能只是一段对话的终结语。加入了"你是实体机器人"这个情境后,LLM 对物理安全指令的敏感度明显提升了。
语气判断:
markdown
## 语气判断(S2S语音优先)
你能听到用户说话的语气。
短促命令式语气 → 倾向调用工具
平缓聊天语气 → 倾向文字回复
语气和文字意思有矛盾时,以语气为准。
这是 S2S 模型对比传统 ASR 管线的最大优势------S2S 模型能感知语调、语速、情绪。传统 ASR→NLU 管线只能拿到文本,丢失了这些信息。用 Prompt 让 LLM 利用这些音频特征做消歧:轻声说"跳个舞吧"是建议,急促说"跳个舞!"是指令。
Prompt 调优的几条经验
回头来看,三版 Prompt 的迭代其实每版都解决了一个具体问题:
| 版本 | 核心改动 | 解决的问题 |
|---|---|---|
| V1 | 聚集式 Tools + Q仔人设 | 从 0 到 1,FC 能跑了 |
| V1.5 | 改成 5 个独立 function | 聚集式准确率低,分散降低决策空间 |
| V2 | 口语映射 + 反例规则 | LLM 不理解同义词,总误触发 |
| V3 | 情境感知 + 语气判断 | 物理安全指令被忽略、语气歧义 |
几个值得记住的思路:
-
描述式 Prompt(V1 的人设风格)适合聊天,但不适合精确的工具调用。工具调用需要的是规则式 Prompt------白名单、禁止列表、具体词汇表。
-
反例比正例更有效。告诉 LLM"什么时候该调"不如告诉它"什么时候不该调"------因为误调的代价远大于漏调。'我说威震天你就变形'这个反例就是最典型的。
-
LLM 是一个黑盒,但 Prompt 是可以调试的。虽然不知道 LLM 内部怎么决策,但可以在 Prompt 里加日志输出、列出允许和禁止的词汇、用边缘 case 去边界探测。
-
端侧代码兜底永远是最后一道防线 。Prompt 写得再好,LLM 仍有随机性。后续的二次确认修复(
system/confirm端侧拦截)就是靠代码兜底来解决 Prompt 解决不了的问题。
和现有系统的关系
FC 打通之后,机器人有了两条完全不同的指令通路:
用户说话
│
├── 交互云 OmniSDK → NLU → HandleActionCallback → DispatchSkillEvent → SkillManager
│ (动作控制,白盒,可靠)
│
└── 豆包 RTC → 方舟 LLM → FC → parse_and_transform → m_skillCallback → SkillManager
(闲聊 + FC,黑盒,自然)
交互云的 NLU 和豆包的 FC 本质是同一件事------把自然语言变成结构化指令。区别在于实现方式:NLU 靠规则+专用模型,FC 靠 LLM+Prompt。后续的工作会进一步明确:交互云做动作(确定性强),豆包做聊天+音乐+VLM(自然、开放)。两条通路各管各的,最终都汇到 SkillManager。