FunctionCall实现与Prompt调优

让豆包能"听懂人话"------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/hdsAgentModule/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/yesconfirmation/no。加起来一共 4 个 function。

格式 B:分散式------每个动作独立的 function

备份文件 .back 里是另一种风格,每个动作独立成一个 function------action/danceaction/wave_handsstatus/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 根据 typevalue 去匹配 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 情境感知 + 语气判断 物理安全指令被忽略、语气歧义

几个值得记住的思路:

  1. 描述式 Prompt(V1 的人设风格)适合聊天,但不适合精确的工具调用。工具调用需要的是规则式 Prompt------白名单、禁止列表、具体词汇表。

  2. 反例比正例更有效。告诉 LLM"什么时候该调"不如告诉它"什么时候不该调"------因为误调的代价远大于漏调。'我说威震天你就变形'这个反例就是最典型的。

  3. LLM 是一个黑盒,但 Prompt 是可以调试的。虽然不知道 LLM 内部怎么决策,但可以在 Prompt 里加日志输出、列出允许和禁止的词汇、用边缘 case 去边界探测。

  4. 端侧代码兜底永远是最后一道防线 。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。

相关推荐
AI 编程助手GPT1 小时前
ChatGPT 新手入门与实战操作指南
开发语言·人工智能·git·python·chatgpt
Elastic 中国社区官方博客1 小时前
使用 Jina CLIP v2 和 Elasticsearch 实现多语言图片搜索
大数据·数据库·人工智能·elasticsearch·搜索引擎·ai·jina
Agent手记1 小时前
电信装维如何智能派单?AI 工程师匹配原理与智能体架构拆解
人工智能·ai·架构
原创小甜甜1 小时前
OOM 排查复盘:Hutool 序列化 Request 导致 Java Heap Space
java·开发语言·python
gf13211111 小时前
【精确查找python脚本是否在运行】
linux·前端·python
zhangfeng11331 小时前
DeepSeek V4 适配华为昇腾950 难度及开源情况
人工智能·pytorch·python·机器学习·华为·开源
searchforAI1 小时前
Ai好记 vs Get笔记:AI音视频笔记工具深度测评对比
人工智能·笔记·学习·ai·音视频·语音识别
MU在掘金916951 小时前
给AI Agent做一个代码大脑:我用Tree-sitter+ChromaDB+MCP搭了个代码知识库
git·python
AI原来如此1 小时前
工具篇 Writesonic:AI写作自带事实核查
ai·大模型·ai编程·ai写作