把豆包接进机器人------端到端方案
目录
- 原来的交互云是怎么工作的
- 为什么还要接一个豆包
- 豆包的混合编排是怎么回事
- 整体架构:接进来之后变成什么样
- [集成第一步:把火山 SDK 搬进来](#集成第一步:把火山 SDK 搬进来)
- 集成第二步:写封装层
- 集成第三步:改项目原有代码
- 集成第四步:加配置文件
- 整个启动流程串一遍
- 数据是怎么流的
- 一些设计上的选择
相关文档:接入端到端实时语音大模型
1. 原来的交互云是怎么工作的
在接入豆包之前,机器人只有一个语音交互通道,就是我们自研的交互云(OmniSDK)。它是怎么工作的呢?
1.1 音频采集管线
首先要搞清楚音频是怎么收进来的。打开 module.cpp,在 OnInitialize() 里有一段 LoadInputAudioHandler():
cpp
// module.cpp --- LoadInputAudioHandler()
bool AgentModule::LoadInputAudioHandler() {
input_audio_handler_ = std::make_shared<input::Audio>();
input_audio_handler_->Init();
return true;
}
这个 input::Audio 类干的事情就是打开 PortAudio 设备,从麦克风收 PCM 音频流。收到之后经过两层处理:
-
AEC(回声消除) ------用的
third_party/aec/lib/libAEC3.a。机器人在播 TTS 的时候如果同时开着麦,会把"自己说的话"也录进去。AEC 就是用来消掉这部分回声的,让云端只听到用户的声音。 -
VAD(语音活动检测) ------用的
third_party/evad/lib/libevad_pipeline.so。判断用户是在说话还是静默中。它会标记三种状态:开始说话(Bos)、持续说话(Continue)、说完(Eos)。
处理完的音频通过 AgentChannel(项目内部的一个发布订阅机制)发出来,Controller 订阅到之后推给 OmniSDK。
1.2 Controller 怎么把音频送给交互云
打开 controller.cpp,音频处理和推送的逻辑在这里:
cpp
// controller.cpp --- InitAgentInternalChannel() 中订阅 VAD 音频
AgentChannel::GetInstance().SubscribeFlexible<std::shared_ptr<input::AudioData>>(
input::kTopicAudioInputBaseName + input::kTopicVadData,
"aima_agent_config",
[this](const std::shared_ptr<input::AudioData>& vad_audio_data) {
ProcessVadAudioData(vad_audio_data); // ← 处理并推给 OmniSDK
});
cpp
// controller.cpp --- ProcessVadAudioData()
void Controller::ProcessVadAudioData(const std::shared_ptr<input::AudioData>& vad_audio_data) {
// ... 省略参数校验 ...
std::shared_ptr<omnissdk::AudioData> audio_data = std::make_shared<omnissdk::AudioData>();
audio_data->data = vad_audio_data->raw_data->data(); // PCM 原始数据
audio_data->len = size;
audio_data->status = vad_status; // Bos/Continue/Eos
audio_data->sample_rate = 16000;
audio_data->channels = 1;
audio_data->precision = 16;
audio_data->audio_type = omnissdk::AudioType::kAudioTypeMicVAD;
option_.omnis_core->ProcessAudioData(audio_data); // ← 推给交互云
}
到这里,PCM 音频被 omnis_core_->ProcessAudioData() 通过 WebSocket 发到了我们云端。这就是上行的路。
1.3 交互云处理完怎么回来
交互云在云端做了两件事:ASR 把语音变成文字,NLU 把文字变成意图和动作。处理完的结果通过 OmniSDK 的回调返回来。RegisterOmnisCallback() 在 Controller 里注册了 8 个回调:
cpp
// controller.cpp --- RegisterOmnisCallback()
bool Controller::RegisterOmnisCallback() {
// 1. NLG 结果:云端生成了要播报的文本
option_.omnis_core->RegisterNlgCallback(
std::bind(&Controller::HandleNlgResult, this, std::placeholders::_1));
// 2. 中断事件:用户打断了当前对话
option_.omnis_core->RegisterInterruptEventCallback(
std::bind(&Controller::HandleInterruptResult, this, std::placeholders::_1));
// 3. 动作结果:NLU 解析出了用户意图(想跳舞、想变形...)
option_.omnis_core->RegisterActionCallback(
std::bind(&Controller::HandleActionCallback, this, std::placeholders::_1));
// 4. 唤醒结果:检测到唤醒词("你好元仔")
option_.omnis_core->RegisterWakeUpCallback(
std::bind(&Controller::HandleWakeUpResult, this, std::placeholders::_1));
// 5. ASR 结果:识别出的文本
option_.omnis_core->RegisterAsrCallback(
std::bind(&Controller::HandleAsrResult, this, std::placeholders::_1));
// 6. 自定义事件:语言切换、设备绑定、配置同步等
option_.omnis_core->RegisterCustomEventCallback(
std::bind(&Controller::HandleCustomEvent, this, std::placeholders::_1));
// 7. 错误
option_.omnis_core->RegisterErrorCallback(
std::bind(&Controller::HandleError, this, std::placeholders::_1));
// 8. VAD 状态
option_.omnis_core->RegisterVadCallback([this](const omnissdk::VadStatusInfo& info) {
CoreLetMe();
// 用户开始说话时上传用户信息
});
return true;
}
这 8 个回调就是交互云跟机器人之间的全部通信方式。其中最重要的两个是 HandleActionCallback 和 HandleNlgResult。
1.4 有了动作结果之后怎么执行
假设用户说"跳个舞":
音频 → OmniSDK → 云端 ASR → NLU → 返回 ActionResult
{type: "action", value: "dance"}
HandleActionCallback 收到 ActionResult 后,投到线程池异步执行,最终走到 DispatchSkillEvent:
cpp
// controller.cpp --- HandleActionCallback → DispatchSkillEvent
void Controller::HandleActionCallback(const omnissdk::ActionResult& result) {
CoreLetMe();
aimrte::ctx::exe(option_.omnis_sdk_callback_thread_pool).Post([this, result]() {
DispatchSkillEvent(result);
});
}
cpp
// controller.cpp --- DispatchSkillEvent(精简)
bool Controller::DispatchSkillEvent(const omnissdk::ActionResult& result) {
// 把 ActionResult 转成 SkillManager 认识的 nlu_event JSON
nlohmann::json nlu_event;
nlu_event["type"] = result.type; // "action"
nlu_event["value"] = result.value; // "dance"
nlu_event["params"] = params;
// 调 SkillManager 执行
auto taskid = capability::skill::SkillManager::GetInstance().ExecuteSkillAsync(nlu_event);
return true;
}
SkillManager 会根据 nlu_event 去匹配 skills.json 里对应的技能,然后调对应的 Plugin------动作走 MotionActionPlugin,表情走 EmoticonActionPlugin,移动走 MoveActionPlugin,最终通过 AimRT RPC 打到 MC(运动控制)模块让机器人动起来。
1.5 交互云的问题在哪
这套流程在动作控制上很稳------NLU 是白盒,配错了调配置就行,确认流程也是成熟的。但日常对话体验不太好:
- 慢:ASR → NLU → NLG → TTS,每个环节都要进出云端,来回加起来 3-5 秒
- 假:回复是模板化的,NLG 选的模板,TTS 念出来,没有 LLM 那种自然感
- 傻:没法闲聊,也没法联网查东西。你问它"今天天气怎么样",它只能回"我不太明白"
这些问题不是配置层面的,是架构层面的------级联管线的先天缺陷。
2. 为什么还要接一个豆包
豆包(Doubao)是火山引擎的端到端实时语音大模型。跟交互云的级联管线不一样,它是一个模型直接处理语音到语音:
用户说话 → S2S 模型 → 直接出 TTS 音频回来
没有中间的 ASR、NLU、NLG 各个环节,所以延迟能做到 1-2 秒,回复是 LLM 实时生成的,自然得多。而且它底层是方舟大模型,可以做 Function Call 调用工具,也能联网搜索。
但豆包也有问题。它的 Function Call 准确率不如交互云的 NLU------毕竟是靠 Prompt 驱动的,不是靠规则+专用模型。而且出问题了你也调不了,是个黑盒。
所以结论就很简单了:两个都要。聊天用豆包(自然),动作用交互云(可靠)。
3. 豆包的混合编排是怎么回事
火山那边支持一种叫"混合编排"的模式。端侧只需要维护一条 WebRTC 连接,但云端内部有两个模型在同时工作:
用户语音进入云端
│
├── S2S 端到端模型(doubao-s2s-v1)
│ 负责:日常聊天
│ 输出:TTS 音频
│ 如果方舟 LLM 没有 FC 输出 → 采纳这个
│
└── 方舟大模型(ArkV3)
负责:Function Call 决策
输出:tool_calls JSON
如果它输出了 FC → 采纳方舟的,压制 S2S
配置上就靠 OutputMode: 1 来启用:
json
"S2SConfig": {
"OutputMode": 1
}
这里有个很重要的点------端侧不需要感知这个切换。S2S 和方舟 LLM 都走同一条 WebRTC 连接,云端自己决定采纳谁的输出。端侧收的就两种东西:要么是 TTS 音频(来自 S2S),要么是 binary message 里的 FC JSON(来自方舟 LLM)。
4. 整体架构:接进来之后变成什么样
集成豆包之后,机器人有了两条语音通路:
用户说话
│
麦克风 → 音频采集 (PortAudio)
│
┌───────────┴───────────┐
▼ ▼
┌──────────────┐ ┌──────────────────┐
│ OmniSDK │ │ ByteRTC SDK │
│ (WebSocket) │ │ (WebRTC) │
│ │ │ │ │ │
│ 我们的交互云 │ │ 火山豆包云 │
│ │ │ │ │ │
│ NLU 结果 │ │ TTS 音频 / FC │
│ │ │ │ │ │
│ Handle │ │ onRoomBinary │
│ Action │ │ MessageReceived │
│ Callback │ │ │ │
│ │ │ │ m_skillCallback │
│ ▼ │ │ │ │
│ Dispatch │ │ ▼ │
│ SkillEvent │ │ SkillManager:: │
│ │ │ │ ExecuteSkill │
│ │ │ │ Async │
└───────┼──────┘ └───────┼──────────┘
└──────────┬───────────┘
▼
SkillManager::ExecuteSkillAsync()
│
▼
机器人执行动作
要实现上面这个架构,需要分四步来集成:
- 把火山 SDK(ByteRTC)的二进制文件搬进项目
- 写封装层,把 SDK 包成我们好用的接口
- 改项目原有代码(module.cpp、controller.cpp、tts_action_impl.cpp),把豆包"插"进去
- 加配置文件(doubao.json、start_voice_chat_template.json)
5. 集成第一步:把火山 SDK 搬进来
火山官方提供的是编译好的 ARM64 SDK,放在 third_party/doubao/VolcEngineRTC_arm/:
VolcEngineRTC_arm/
├── include/
│ ├── bytertc_engine.h ← 引擎主入口
│ ├── bytertc_room.h ← 房间管理
│ ├── bytertc_engine_event_handler.h ← 引擎级回调(50+ 虚函数)
│ ├── bytertc_room_event_handler.h ← 房间级回调(20+ 虚函数)
│ ├── bytertc_engine_ex.h ← 引擎扩展接口
│ ├── bytertc_room_ex.h ← 房间扩展接口
│ ├── bytertc_game_room.h ← 游戏房间(用不到)
│ ├── bytertc_rts_room.h ← RTS 房间(用不到)
│ ├── byterts.h / byterts_room.h ← 实时信令(用不到)
│ └── rtc/ ← 详细类型定义
│ ├── bytertc_defines.h ← 核心枚举和常量
│ ├── bytertc_audio_defines.h ← 音频相关(采样率、声道、编码)
│ ├── bytertc_video_defines.h ← 视频相关(分辨率、帧率、格式)
│ ├── bytertc_audio_frame.h ← 音频帧结构体
│ ├── bytertc_video_frame.h ← 视频帧结构体
│ ├── bytertc_audio_device_manager.h ← 音频设备管理
│ ├── bytertc_video_device_manager.h ← 视频设备管理
│ ├── bytertc_media_defines.h ← 媒体流定义(快 8000 行)
│ ├── bytertc_transcoder_define.h ← 转码定义
│ ├── bytertc_ktv_defines.h ← KTV 相关(用不到)
│ ├── bytertc_spatial_audio_interface.h ← 空间音频
│ ├── bytertc_range_audio_interface.h ← 范围音频
│ └── ... 其他各种扩展接口
│
└── lib/
├── libVolcEngineRTC.so ← 31M, 核心引擎
├── libRTCFFmpeg.so ← 2.7M, FFmpeg 编解码
├── libbytenn.so ← 29M, 神经网络推理
├── libbytertc_fdk-aac_extension.so ← 0.9M, AAC 编码
├── libbytertc_ffmpeg_audio_extension.so ← 4.2M, FFmpeg 音频扩展
├── libbytertc_nico_extension.so ← 0.7M, 降噪美声
└── libbytertc_vp8codec_extension.so ← 15M, VP8 视频编码
整个 SDK 加起来 80 多 MB,全是二进制。看不到源码,只能看头文件里的接口声明。
CMakeLists.txt 里需要把这些库链进来:
cmake
# third_party/doubao/CMakeLists.txt
set(DOUBAO_LIB_DIR ${CMAKE_CURRENT_SOURCE_DIR}/VolcEngineRTC_arm/lib)
set(DOUBAO_INCLUDE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/VolcEngineRTC_arm/include)
link_directories(${DOUBAO_LIB_DIR})
include_directories(${DOUBAO_INCLUDE_DIR})
include_directories(${DOUBAO_INCLUDE_DIR}/rtc)
6. 集成第二步:写封装层
火山 SDK 固然强大,但接口太底层了。让业务代码直接面对它,那 module.cpp 里光初始化代码就要几百行。所以我们在 third_party/doubao/src/ 下写了封装层。
6.1 RTC 引擎封装(rtc_engine_wrapper)
这是封装层的核心,RTCVideoEngineWrapper 继承了两个 SDK 回调接口,把复杂的东西全包在里面:
cpp
// rtc_engine_wrapper.h(精简后核心结构)
namespace Doubao {
// 回调类型定义
using SkillCallback = std::function<void(const json &skill_json)>; // FC 回调
using SubvCallback = std::function<void(bool is_asr, const string& uid,
const string& text)>; // ASR/LLM 文字
using JpgEncoderCallback = std::function<bool(
const uint8_t* data, int width, int height, int step,
std::vector<uint8_t>& out_jpg)>; // VLM 编码
using AudioPlayCallback = std::function<void(std::shared_ptr<AudioData>)>; // 音频播放
class RTCVideoEngineWrapper : public bytertc::IRTCEngineEventHandler,
public bytertc::IRTCRoomEventHandler {
public:
// ---- 生命周期 ----
int init(); // 创建引擎、初始化音视频
int joinRoom(); // 加入房间、启动智能体
int destory(); // 退出房间、释放资源
// ---- 状态查询 ----
bool isAgentEnabled() const;
bool isAgentRunning() const;
// ---- 回调注册(上层模块通过这些注入能力) ----
void RegisterAudioCallback(const AudioPlayCallback&); // 收 TTS 音频
void RegisterSkillCallback(const SkillCallback&); // 收 FC JSON
void RegisterSubvCallback(const SubvCallback&); // 收 ASR/LLM 文字
void RegisterJpgEncoder(const JpgEncoderCallback& cb); // 注入 JPEG 编码
// ---- VLM 视觉 ----
void SetLatestFrame(const uint8_t* data, int w, int h, int step); // 缓存摄像头帧
bool SendVlmRequest(const string& sys_prompt, const string& usr_prompt);
// ---- 消息发送 ----
void sendTTSMessageToAgent(const string &content); // 发 TTS 文本到豆包
// 引擎端回调(继承 SDK 接口的实现)
void onConnectionStateChanged(ConnectionState state) override;
void onRoomMessageReceived(const char *uid, const char *message) override;
void onRoomBinaryMessageReceived(const char *uid, int size,
const uint8_t *message) override;
// ... 50+ 个回调省略
private:
bytertc::IRTCEngine *m_pVideoEngine = nullptr; // SDK 引擎实例
bytertc::IRTCRoom *m_pRtcRoom = nullptr; // 当前房间
SkillCallback m_skillCallback; // FC 回调
SubvCallback m_subvCallback; // ASR 文字回调
JpgEncoderCallback m_jpgEncoder; // JPEG 编码器
std::mutex m_skillCallbackMutex;
// VLM 配置和帧缓存
string m_vlmUrl, m_vlmModel, m_vlmAuthToken;
vector<uint8_t> m_latestFrameData; // 最新摄像头帧
int m_latestFrameWidth, m_latestFrameHeight;
std::mutex m_latestFrameMutex;
// 音频相关
std::atomic<bool> m_enableAudioObserver{false};
std::unique_ptr<AudioFrameObserver> m_audioObserver;
};
} // namespace Doubao
来看看 init() 和 joinRoom() 怎么实现的:
init() 的核心就是调 IRTCEngine::createRTCEngine() 创建引擎,然后配上音频参数:
cpp
// rtc_engine_wrapper.cc --- init() 核心逻辑
int RTCVideoEngineWrapper::init() {
auto appDataIns = AppDataManager::instance()->getAppData();
// 创建引擎
bytertc::EngineConfig config;
config.app_id = appDataIns->app_id.c_str();
m_pVideoEngine = bytertc::IRTCEngine::createRTCEngine(config, this);
if (m_pVideoEngine == nullptr) {
return -1;
}
// 初始化视频设备
initVideoDevice();
// 初始化音频配置
auto nRet = initAudioConfig();
if (nRet) return nRet;
// 初始化视频配置
nRet = initVideoConfig();
if (nRet) return nRet;
return 0;
}
joinRoom() 关键步骤:创建房间、生成 Token、加入房间、拉起智能体:
cpp
// rtc_engine_wrapper.cc --- joinRoom()
int RTCVideoEngineWrapper::joinRoom() {
auto appDataIns = AppDataManager::instance()->getAppData();
// 创建房间
m_pRtcRoom = m_pVideoEngine->createRTCRoom(appDataIns->room_id.c_str());
m_pRtcRoom->setRTCRoomEventHandler(this);
// 配置用户信息
bytertc::UserInfo user;
user.uid = appDataIns->user_id.c_str();
bytertc::RTCRoomConfig roomConfig;
roomConfig.is_auto_subscribe_audio = true;
roomConfig.is_auto_subscribe_video = false;
// 生成 Token(AppId + AppKey + RoomId + UserId → HMAC)
std::string token = TokenGenerator::generate(
appDataIns->app_id, appDataIns->app_key,
appDataIns->room_id, appDataIns->user_id);
// 加入房间
int nRet = m_pRtcRoom->joinRoom(token.c_str(), user, true, roomConfig);
if (nRet != 0) return nRet;
// 发布音频流
m_pRtcRoom->publishStreamAudio(true);
// 启动智能体(内部调 StartVoiceChat HTTP API)
if (m_enableAgent) {
StartAgentAsync();
}
return 0;
}
注意一个细节:StartAgentAsync() 不是直接调,而是异步的,带了重试机制。因为智能体的 HTTP API 可能比 WebRTC 房间建得慢:
cpp
// rtc_engine_wrapper.cc --- StartAgentAsync()
void RTCVideoEngineWrapper::StartAgentAsync() {
constexpr int kMaxRetries = 3;
std::thread([this]() {
for (int i = 0; i < kMaxRetries; ++i) {
bool ok = DoubaoAgentManager::instance()->startAgent(
m_appData->app_id, m_appData->room_id, m_localUserId);
if (ok) {
m_isAgentRunning = true;
LOG_INFO("agent started (retry " << i << ")");
return;
}
std::this_thread::sleep_for(std::chrono::seconds(1));
}
LOG_ERROR("agent failed after " << kMaxRetries << " retries");
}).detach();
}
6.2 配置管理器(app_data_manager)
AppDataManager 是个单例,唯一的任务是读 JSON 配置文件,解析成结构体。代码不复杂,但很关键------所有模块都从它这儿拿配置。
解析逻辑贴在下面:
cpp
// app_data_manager.cc --- parse() 核心部分
bool AppDataManager::parse(const std::string &json_str) {
auto configJson = json11::Json::parse(json_str, err);
m_appData = std::make_shared<StuAppData>();
m_appData->app_id = configJson["app_id"].string_value();
m_appData->room_id = configJson["room_id"].string_value();
m_appData->user_id = configJson["user_id"].string_value();
m_appData->app_key = configJson["app_key"].string_value();
m_appData->enable_video = configJson["enable_video"].bool_value();
m_appData->enable_external_audio = configJson["enable_external_audio"].bool_value();
// ... 省略中间字段 ...
// 解析豆包智能体配置
auto agentConfigJson = configJson["doubao_agent_config"];
auto agentConfig = std::make_shared<StuDoubaoAgentConfig>();
agentConfig->enable_doubao_agent = agentConfigJson["enable_doubao_agent"].bool_value();
agentConfig->token = agentConfigJson["token"].string_value();
agentConfig->access_id = agentConfigJson["access_id"].string_value();
agentConfig->secret_access_key = agentConfigJson["secret_access_key"].string_value();
agentConfig->endpoint = agentConfigJson["endpoint"].string_value();
agentConfig->enable_s2s = agentConfigJson["enable_s2s"].bool_value();
agentConfig->s2s_model = agentConfigJson["s2s_model"].string_value();
agentConfig->s2s_voice_type = agentConfigJson["s2s_voice_type"].string_value();
agentConfig->language = agentConfigJson["language"].string_value();
agentConfig->sample_rate = agentConfigJson["sample_rate"].int_value();
agentConfig->enable_vad = agentConfigJson["enable_vad"].bool_value();
agentConfig->vad_silence_timeout = agentConfigJson["vad_silence_timeout"].int_value();
agentConfig->start_request_temple_file =
agentConfigJson["start_request_temple_file"].string_value();
m_appData->doubao_agent_config = agentConfig;
// 解析 VLM 配置
auto vlmJson = configJson["vlm"];
auto vlmCfg = std::make_shared<StuVlmConfig>();
vlmCfg->url = vlmJson["url"].string_value();
vlmCfg->model = vlmJson["model"].string_value();
vlmCfg->system_prompt = vlmJson["system_prompt"].string_value();
vlmCfg->auth_token = vlmJson["auth_token"].string_value();
vlmCfg->jpg_quality = vlmJson["jpg_quality"].int_value();
vlmCfg->timeout_ms = vlmJson["timeout_ms"].int_value();
m_appData->vlm_config = vlmCfg;
// 解析音频观察者配置
auto audioConfigJson = configJson["audio_observer_config"];
auto audioConfig = std::make_shared<StuAudioObserverConfig>();
audioConfig->enable_audio_observer = audioConfigJson["enable_audio_observer"].bool_value();
audioConfig->enable_playback_audio = audioConfigJson["enable_playback_audio"].bool_value();
audioConfig->enable_remote_user_audio = audioConfigJson["enable_remote_user_audio"].bool_value();
audioConfig->enable_mixed_audio = audioConfigJson["enable_mixed_audio"].bool_value();
audioConfig->preroll_ms = audioConfigJson["preroll_ms"].int_value();
m_appData->audio_observer_config = audioConfig;
// 校验:如果启用了智能体,必须有密钥
if (m_appData->doubao_agent_config &&
m_appData->doubao_agent_config->enable_doubao_agent) {
if (m_appData->doubao_agent_config->token.empty() ||
m_appData->doubao_agent_config->secret_access_key.empty()) {
LOG_WARN("doubao agent enabled but credentials missing!");
return false;
}
}
return true;
}
// 从文件加载 JSON 字符串,再调 parse()
bool AppDataManager::load(const std::string &json_file) {
auto data = bytertc::readFile(json_file.c_str(), "rt");
// ... 提取 JSON 部分 ...
return parse(str);
}
结构体定义(app_data_manager.h):
cpp
struct StuDoubaoAgentConfig {
bool enable_doubao_agent = false;
std::string agent_user_id, task_id, app_id, token;
std::string access_id, secret_access_key;
std::string region = "cn-north-1";
std::string endpoint = "https://rtc.volcengineapi.com";
std::string start_request_temple_file;
// S2S 端到端配置
bool enable_s2s = true;
std::string s2s_model = "doubao-s2s-v1";
std::string s2s_voice_type = "BV700_streaming";
std::string language = "zh-CN";
int sample_rate = 16000;
bool enable_vad = true;
int vad_silence_timeout = 500;
};
struct StuVlmConfig {
std::string url, model, system_prompt, auth_token;
int jpg_quality = 80;
int timeout_ms = 5000;
};
struct StuAudioObserverConfig {
bool enable_audio_observer = false;
bool save_to_file = false;
std::string file_path;
bool enable_record_audio = true;
bool enable_playback_audio = true; // ← 收豆包 TTS
bool enable_remote_user_audio = true;
bool enable_mixed_audio = true;
int preroll_ms = 100;
};
struct StuAppData {
std::string app_id, app_key, room_id, user_id;
bool enable_video, enable_external_audio, enable_external_video;
std::shared_ptr<StuVideoCaptureConfig> video_capture_config;
std::shared_ptr<StuVideoEncoderConfig> video_encoder_config;
std::shared_ptr<StuDoubaoAgentConfig> doubao_agent_config;
std::shared_ptr<StuAudioObserverConfig> audio_observer_config;
std::shared_ptr<StuVlmConfig> vlm_config;
};
6.3 智能体管理器(doubao_agent_manager)
WebRTC 只负责音视频传输。但豆包智能体的创建、配置人设、配置 FC 工具、销毁------这些是"控制面"的活,通过 HTTP API 完成。
cpp
// doubao_agent_manager.cc --- startAgent() 核心
bool DoubaoAgentManager::startAgent(const string &app_id,
const string &room_id,
const string &target_user_id) {
// 保存房间信息,后面 StopVoiceChat 要用
m_roomId = room_id;
m_rtcAppId = app_id;
// 构造请求体:读模板 JSON + 填动态参数
std::string requestBody = buildStartVoiceChatRequest(app_id, room_id, target_user_id);
// 构造 URL
std::string apiUrl = m_config->endpoint +
"/?Action=StartVoiceChat&Version=2024-12-01";
// 生成 V4 签名认证头
auto authHeaders = generateAuthHeaders("POST", "/", requestBody);
authHeaders["Accept"] = "application/json";
authHeaders["Content-Type"] = "application/json";
// 发送 HTTP POST
auto response = HttpClient::post(apiUrl, requestBody, authHeaders);
if (response.status_code != 200) {
LOG_ERROR("StartVoiceChat failed: " << response.status_code);
return false;
}
m_isRunning = true;
return true;
}
buildStartVoiceChatRequest() 做拼接------读模板文件,把空字段填上:
cpp
// doubao_agent_manager.cc --- buildStartVoiceChatRequest()
std::string DoubaoAgentManager::buildStartVoiceChatRequest(
const string &app_id, const string &room_id,
const string &target_user_id) {
// 读模板文件
json request_temple_json;
std::ifstream stream(m_config->start_request_temple_file);
stream >> request_temple_json;
// 填入动态值
request_temple_json["AppId"] = app_id;
request_temple_json["RoomId"] = room_id;
request_temple_json["TaskId"] = m_config->task_id;
// 填入 AgentConfig
auto& agent = request_temple_json["AgentConfig"];
agent["UserId"] = m_config->agent_user_id;
agent["TargetUserId"].push_back(target_user_id);
// 填入 S2S 鉴权信息
auto& s2s = request_temple_json["Config"]["S2SConfig"];
s2s["ProviderParams"]["app"]["appid"] = m_config->app_id;
s2s["ProviderParams"]["app"]["token"] = m_config->token;
return request_temple_json.dump();
}
V4 签名的实现(generateAuthHeaders),大致分这几步:
1. 对请求体做 SHA256 哈希
2. 构造规范请求字符串(方法+URI+查询参数+头部+签名头+哈希)
3. 对规范请求做 SHA256
4. 构造待签名字符串(HMAC-SHA256 + 时间戳 + 凭据范围 + 哈希)
5. 用 SecretAccessKey 层层 HMAC 算出签名密钥链
kSecret → kDate → kRegion → kService → kSigning
6. 用签名密钥对字符串签名
7. 组装 Authorization 头
cpp
// generateAuthHeaders 的核心签名计算(简化)
std::map<string, string> DoubaoAgentManager::generateAuthHeaders(
const string &method, const string &uri,
const string &body, const string &action) {
// 时间戳
auto now = std::time(nullptr);
string timestamp = /* 格式化成 YYYYMMDDThhmmssZ */;
string datestamp = timestamp.substr(0, 8);
// 请求体 SHA256
string payload_hash = sha256Hex(body);
// 规范请求
string canonical_request = method + "\n"
+ "/\n"
+ "Action=" + action + "&Version=2024-12-01\n"
+ "content-type:application/json\n"
+ "host:rtc.volcengineapi.com\n"
+ "x-content-sha256:" + payload_hash + "\n"
+ "x-date:" + timestamp + "\n\n"
+ "content-type;host;x-content-sha256;x-date\n"
+ payload_hash;
// 规范请求的哈希
string canonical_hash = sha256Hex(canonical_request);
// 待签名字符串
string credential_scope = datestamp + "/cn-beijing/rtc/request";
string string_to_sign = "HMAC-SHA256\n" + timestamp + "\n"
+ credential_scope + "\n" + canonical_hash;
// HMAC 密钥链:kSecret → kDate → kRegion → kService → kSigning
auto kDate = hmacSHA256(m_config->secret_access_key, datestamp);
auto kRegion = hmacSHA256(kDate, "cn-beijing");
auto kService = hmacSHA256(kRegion, "rtc");
auto kSigning = hmacSHA256(kService, "request");
// 最终签名
auto signature = hmacSHA256Hex(kSigning, string_to_sign);
// 组装 Authorization 头
string authorization = "HMAC-SHA256 Credential="
+ m_config->access_id + "/" + credential_scope
+ ", SignedHeaders=content-type;host;x-content-sha256;x-date"
+ ", Signature=" + signature;
map<string, string> headers;
headers["Authorization"] = authorization;
headers["X-Date"] = timestamp;
headers["X-Content-Sha256"] = payload_hash;
headers["Content-Type"] = "application/json";
headers["Host"] = "rtc.volcengineapi.com";
return headers;
}
6.4 音频帧观察者(audio_frame_observer)
豆包回复的时候,火山云端把合成好的 TTS 音频通过 WebRTC 一帧一帧发回来。AudioFrameObserver 负责接收,继承 SDK 的 IAudioFrameObserver 接口:
cpp
// audio_frame_observer.h
namespace Doubao {
class AudioFrameObserver : public bytertc::IAudioFrameObserver {
public:
struct AudioStats {
uint64_t recordFrameCount = 0;
uint64_t playbackFrameCount = 0; // 豆包 TTS 走了多少帧
uint64_t remoteUserFrameCount = 0;
uint64_t mixedFrameCount = 0;
};
void RegisterAudioCallback(const AudioPlayCallback &cb);
void onRecordAudioFrame(const bytertc::IAudioFrame *frame) override;
void onPlaybackAudioFrame(const bytertc::IAudioFrame *frame) override;
void onRemoteUserAudioFrame(const bytertc::IAudioFrame *frame) override;
void onMixedAudioFrame(const bytertc::IAudioFrame *frame) override;
AudioStats getStats() const { return m_stats; }
private:
AudioPlayCallback m_audioCallback; // ← 回调给 AgentModule 播放
AudioStats m_stats;
};
}
6.5 其他小模块
- TokenGenerator:根据 AppId、AppKey、RoomId、UserId 生成 WebRTC 房间的入房 Token。用了 Packer 做二进制序列化,utils.h 里有字符串和 hex 的转换工具。
- http_client:一个轻量的 HTTP POST 封装,用于 VLM 请求和智能体管理 API。
- util/thread_loop.h :定时循环的线程封装,RTC 引擎初始化时用来周期推送外部音频帧(虽然当前
enable_external_audio: false所以没用到)。 - util/json.hpp:nlohmann/json 单头文件库,25000 行。所有 JSON 操作都用它。json11 是另一个 JSON 库,被 app_data_manager 用(因为火山 SDK 的 demo 代码用 json11,我们沿用了)。
7. 集成第三步:改项目原有代码
有了封装层之后,接下来就是把豆包"插"进项目的启动流程里。涉及三个文件的改动。
7.1 module.cpp --- 启动时初始化豆包
在 AgentModule::OnStart() 里,所有原有启动步骤执行完之后,加入豆包初始化。完整的顺序是这样的:交互云和业务服务要先起来,麦克风要等豆包就绪再开------因为豆包 RTC SDK 内部自己会打开音频设备,如果麦开在豆包之前,可能会有设备占用冲突。
来看改动的代码:
cpp
// module.cpp --- AgentModule::OnStart() 中新增的豆包部分
// 位置:info_input_impl_->Start() 之后,input_audio_handler_->OnStart() 之前
// ===== 步骤 1:加载 doubao.json 配置 =====
AIMRTE_WARN("AgentModule OnStart Start Doubao::AppDataManager");
std::string configJsonFilePath{"cfg/t1/doubao.json"}; // FIXME 先给定一个临时的路径
auto appDataIns = Doubao::AppDataManager::instance();
if (!appDataIns->load(configJsonFilePath)) {
AIMRTE_ERROR("Failed to load appDataIns");
return false;
}
// ===== 步骤 2:获取 RTC 实例,初始化引擎 =====
AIMRTE_WARN("AgentModule OnStart Start Doubao::RTCVideoEngineWrapper");
auto rtcWrapper = Doubao::RTCVideoEngineWrapper::instance();
auto nRet = rtcWrapper->init();
if (nRet) {
AIMRTE_ERROR("Failed to init rtcWrapper");
return false;
}
// ===== 步骤 3:加入 WebRTC 房间 =====
nRet = rtcWrapper->joinRoom();
if (nRet) {
AIMRTE_ERROR("Failed to join room");
return false;
}
// 此时 WebRTC 连接已建立,音频通道通了!
// ===== 步骤 4:注册 VLM JPEG 编码回调 =====
rtcWrapper->RegisterJpgEncoder(
[](const uint8_t* data, int width, int height, int step,
std::vector<uint8_t>& out_jpg) -> bool {
if (!data || width <= 0 || height <= 0 || step <= 0) return false;
cv::Mat frame(height, width, CV_8UC3, const_cast<uint8_t*>(data), step);
std::vector<int> params = {cv::IMWRITE_JPEG_QUALITY, 80};
return cv::imencode(".jpg", frame, out_jpg, params);
});
AIMRTE_INFO("VLM JPG encoder registered to RTC wrapper");
// ===== 步骤 5:把 RTC 指针注入 Controller =====
controller_.SetRtcWrapper(rtcWrapper);
AIMRTE_INFO("VLM rtc wrapper injected into controller");
// ===== 步骤 6:注册音频接收回调(收豆包 TTS → 播放) =====
auto appData = appDataIns->getAppData();
if (appData->audio_observer_config &&
appData->audio_observer_config->enable_audio_observer) {
rtcWrapper->setAudioObserverConfig(
appData->audio_observer_config->enable_record_audio,
appData->audio_observer_config->enable_playback_audio,
appData->audio_observer_config->enable_remote_user_audio,
appData->audio_observer_config->enable_mixed_audio);
if (rtcWrapper->enableAudioFrameObserver(true)) {
// 注册音频回调------收到豆包 TTS 帧就播
rtcWrapper->RegisterAudioCallback(
[this, preroll_ms = appData->audio_observer_config->preroll_ms]
(const std::shared_ptr<Doubao::AudioData>& data) {
CoreLetMe(); // 收到豆包回复时亮屏
// 预缓冲逻辑:刚开始收到音频时不立即播,
// 先缓冲 preroll_ms 毫秒,防止开头卡顿或爆音
// ... (约 100 行缓冲逻辑,此处省略)
// 正常播放
output::AudioPlayStream stream;
stream.audio_data = data->data;
stream.data_len = data->count;
stream.sample_rate = data->sampleRate;
stream.channels = data->channels;
stream.trace_id = "test";
output_audio_handler_->PlayAudioStream(stream);
});
}
}
// ===== 步骤 7:注册 FC 技能回调 =====
rtcWrapper->RegisterSkillCallback([this](const nlohmann::json& nlu_input) {
CoreLetMe();
capability::skill::SkillManager::GetInstance().ExecuteSkillAsync(nlu_input);
});
// ===== 步骤 8:启用 kPlayback 音频回调 =====
rtcWrapper->enablePlaybackAudioCallback(true);
// ===== 步骤 9:检查智能体是否正常启动 =====
if (rtcWrapper->isAgentEnabled()) {
if (rtcWrapper->isAgentRunning()) {
AIMRTE_INFO("豆包智能体已启动并运行中");
} else {
AIMRTE_ERROR("豆包智能体启用但未运行------请检查配置文件中的访问密钥");
return false;
}
} else {
AIMRTE_ERROR("豆包智能体未启用------请检查 doubao_agent_config 配置");
return false;
}
另外在 OnShutdown() 里加了清理:
cpp
// module.cpp --- OnShutdown() 新增
Doubao::RTCVideoEngineWrapper::instance()->destory();
// → 退出 RTC 房间、调 StopVoiceChat 销毁智能体、释放引擎资源
7.2 controller.cpp/h --- 视频帧推给 RTC
Controller 里加了一个 rtc_wrapper_ 成员变量,和对应的 setter:
cpp
// controller.h 新增
#include <atomic>
#include "rtc_engine_wrapper.h" // 前向声明 Doubao::RTCVideoEngineWrapper
// 成员变量
std::atomic<Doubao::RTCVideoEngineWrapper*> rtc_wrapper_{nullptr};
// Setter
void Controller::SetRtcWrapper(Doubao::RTCVideoEngineWrapper* w) {
rtc_wrapper_.store(w, std::memory_order_release);
AIMRTE_INFO("Controller: rtc wrapper injected ({})", (w ? "ok" : "null"));
}
用 std::atomic 是因为 RTC 回调在 SDK 自己的线程里跑,Controller 的 HandleVideoData 在主线程跑,两个线程读写同一个指针,必须保证线程安全。
然后在 HandleVideoData() 末尾,每收到一帧摄像头图像,就推一份到 RTC Wrapper 缓存:
cpp
// controller.cpp --- HandleVideoData() 末尾新增
auto* wrapper = rtc_wrapper_.load(std::memory_order_acquire);
if (wrapper && frame->isContinuous()) {
wrapper->SetLatestFrame(frame->data, frame->cols, frame->rows,
static_cast<int>(frame->step));
AIMRTE_DEBUG("VLM: pushed frame to wrapper, {}x{}", frame->cols, frame->rows);
}
这里推给 RTC 的帧不是给豆包实时看的,是缓存下来。后面用户说"你看到了什么"触发 VLM Function Call 时,端侧拿这帧去调 VLM 服务,所以说这是"为 VLM 预留的数据通道"。
7.3 tts_action_impl.cpp --- TTS 改成走豆包
这是集成豆包之后最大的行为变化。原来机器人的 TTS 播报是通过 ROS2 RPC 调本地 TTS 模块(play_tts_proxy_->Func.Call(...))。接入豆包后,TTS 交给豆包云端去做,文字发过去,云端合成语音,通过 WebRTC 把音频传回来,RegisterAudioCallback 里收到后自动播放。
原来的代码被注释保留(方便回退),新的逻辑是:
cpp
// tts_action_impl.cpp --- WorkerLoop() 中的改动
// ===== 原来的方式(被注释掉) =====
// aimdk_msgs::srv::PlayTts_Response tts_rsp;
// aimrt::rpc::Context rpc_ctx;
// rpc_ctx.SetTimeout(std::chrono::seconds(4));
// auto res = play_tts_proxy_->Func.Call(rpc_ctx, task->req, tts_rsp).Sync();
// if (!res.OK()) {
// ret = {false, ""};
// } else {
// ret = {tts_rsp.tts_resp.is_success, tts_rsp.tts_resp.trace_id};
// }
// ===== 新的方式:发给豆包 =====
auto rtcWrapper = Doubao::RTCVideoEngineWrapper::instance();
rtcWrapper->sendTTSMessageToAgent(task->req.tts_req.text);
// 构造 TTS 状态,发布到 AgentChannel
auto tts_ptr = std::make_shared<TtsStatus>(TtsStatus{
.text = task->req.tts_req.text,
.priority = task->req.tts_req.priority_level.value,
.trace_id = task->req.tts_req.trace_id,
.tts_status = ActionStatus::kWaiting,
.domain = task->req.tts_req.domain,
.error_message = ""});
AgentChannel::GetInstance().Publish(kTopicTtsStatus, tts_ptr);
ret = {true, task->req.tts_req.trace_id};
注意:这里还顺手把默认语言从 "en" 改成了 "zh",因为豆包目前只支持中文:
cpp
// GetAndPlayNLGText 里的改动
- std::string language = "en";
+ std::string language = "zh";
8. 集成第四步:加配置文件
接入豆包之后,新增了 2 个关键配置文件,改了 1 个。这三个文件各管一摊,缺一不可。
先搞清楚每个文件在整体流程中的位置:
开机流程 对应的配置文件
──────── ────────────
① 交互云先启动 llm.config(原有)
② 业务服务 skills.json(原有)
③ 读 doubao.json ─────────────→ doubao.json ← 豆包连接、智能体、VLM 配置
④ 初始化 RTC ↑ 被 AppDataManager::load() 读了
⑤ 加入房间 ↑ app_id/token 用于 RTC 鉴权
⑥ 注册回调 ↑ audio_observer_config 决定收哪些音频
↑ vlm 决定 VLM 服务的地址
⑦ 启动智能体 ──────────────────→ start_voice_chat_template.json ← 豆包的人设+FC工具
HTTP POST StartVoiceChat ↑ 被 DoubaoAgentManager 读了
云端拿到模板 ↑ 整个 JSON 填好参数发给火山
⑧ 开麦
agent.yaml ← 新增了两个 executor 线程
↑ 被 AimRT 框架在模块初始化时读了
下面逐个细说。
8.1 doubao.json --- 豆包的"连接说明书"
这个文件被 AppDataManager::load() 在开机时读取。什么时候读的?就是在 OnStart() 里,豆包初始化的第一步:
cpp
// module.cpp --- 豆包初始化的第一行代码
std::string configJsonFilePath{"cfg/t1/doubao.json"};
auto appDataIns = Doubao::AppDataManager::instance();
appDataIns->load(configJsonFilePath);
// ← 读完这个 JSON,所有配置都在内存里了,后续的 init/joinRoom 都从里面取参数
整个文件可以切成六段来理解:
doubao.json
│
├── [第一部分] RTC 基础连接参数
│ app_id, app_key ← 火山控制台的应用凭证
│ room_id, user_id ← 房间号和用户名
│ enable_audio ← 是否启用音频
│ enable_external_audio ← false=让SDK自己采麦
│ audio_device_index: -1 ← 用系统默认麦克风
│
├── [第二部分] 视频参数(当前没用,备用)
│ enable_video: false ← 不启用视频
│ video_capture_config ← 分辨率、帧率
│ video_encoder_config ← 编码参数
│
├── [第三部分] 豆包智能体配置 --- 核心
│ enable_doubao_agent: true ← 总开关,false 的话豆包不启动
│ enable_s2s: true ← 用端到端模型还是传统三段式
│ s2s_model: doubao-s2s-v1 ← S2S 模型版本
│ s2s_voice_type ← 音色
│ language: zh-CN ← 语言
│ sample_rate: 16000 ← 采样率
│ enable_vad: true ← VAD 开关
│ vad_silence_timeout: 300 ← 说完话静默多久判定"说完了"
│ token, access_id, secret_access_key ← 鉴权(调 HTTP API 用)
│ endpoint: https://rtc.volcengineapi.com ← 火山 API 地址
│ start_request_temple_file → → → 指向下一个配置文件!
│
├── [第四部分] 音频接收配置
│ enable_audio_observer: true
│ enable_playback_audio: true ← 收豆包的 TTS 播报
│ enable_remote_user_audio: true ← 收远端音频
│ enable_mixed_audio: true ← 收混合音频
│ enable_record_audio: false ← 不收自己的录音
│ preroll_ms: 100 ← 开播前预缓冲 100ms
│
├── [第五部分] VLM 视觉配置
│ url: http://10.3.192.9:8089/... ← VLM 服务地址(内网)
│ model: service_vlm_dev_h20 ← 模型名称
│ system_prompt: "用简明扼要的中文..." ← VLM 提示词
│ jpg_quality: 80 ← JPEG 压缩质量
│ timeout_ms: 5000 ← 请求超时
│
└── [第六部分] 设备索引
video_device_index: 0 ← 用第一个摄像头
逐段说明:
第一部分:RTC 基础连接参数
这部分直接决定能不能连上火山引擎的 RTC 服务。app_id 和 app_key 是在火山控制台创建应用后拿到的,room_id 和 user_id 是自定义的------一个 room 就是一个"聊天室",机器人加入这个 room,智能体也加入这个 room,两边就能互相听到了。
enable_external_audio: false 是个关键的配置。false 的意思是"让 ByteRTC SDK 自己从 ALSA 打开麦克风设备采集音频",我们的代码不用 push 音频进去。如果设成 true,就需要我们自己调 pushExternalAudioFrame() 往引擎里喂音频数据。当前设为 false 是因为 ByteRTC SDK 内置了音频采集模块,直接用就行。
第二部分:视频参数
当前 enable_video: false,所以这部分暂时没用上。但是如果以后要启用视频通话或者推视频流给豆包做实时视觉理解,就要把这里打开。video_capture_config 定了采集分辨率 1280x720、30fps,video_encoder_config 定了编码后最大码率 3000kbps。
第三部分:豆包智能体配置------最重要的一段
这是整个 doubao.json 最核心的部分。
enable_doubao_agent: true 是总开关。module.cpp 里有一行检查:if (!rtcWrapper->isAgentEnabled()) { return false; }------如果这里是 false,豆包初始化就会失败,机器人就只用交互云。
enable_s2s: true 决定用端到端模型还是传统三段式。端到端模式下,一个 S2S 模型直接处理语音→语音,延迟低、回复自然。传统三段式就是 ASR→LLM→TTS 各走各的。当前的混合编排方案要求 enable_s2s: true。
start_request_temple_file: "cfg/t1/start_voice_chat_template.json" 是指向下一个配置文件的路径。DoubaoAgentManager::buildStartVoiceChatRequest() 里会读这个文件,填上动态参数后发给火山创建智能体。
token、access_id、secret_access_key 这三个是调用火山 HTTP API 的鉴权凭据。token 就是普通的 API Token,access_id 和 secret_access_key 用来做 V4 签名(就是前面 generateAuthHeaders() 里那套 HMAC-SHA256 签名链)。
endpoint: https://rtc.volcengineapi.com 是火山 API 的地址。DoubaoAgentManager 会往这个地址 POST / + ?Action=StartVoiceChat。
第四部分:音频接收配置
决定端侧收豆包云端的哪些音频流。enable_playback_audio: true 是最关键的------收豆包的 TTS 播报音频,收回来之后通过 RegisterAudioCallback 注册的回调播放出去,用户才能听到豆包说话。
preroll_ms: 100 是一个小优化。WebRTC 刚开始收到音频时帧可能不完整,如果直接播会导致开头卡一下或者爆音。所以代码里做了一个三段状态机:
Silent(静默)
→ 收到第一帧非零数据 → Buffering(缓冲中)
→ jitter_buf 攒够 preroll_ms 毫秒的数据
→ 一次性写入音频播放器 → Streaming(流式播放)
→ 逐帧播放
如果中间收到零帧(静默)→ 回到 Silent
这个状态机在 RegisterAudioCallback 的 lambda 里实现,大概 100 行,核心逻辑就是这样。
第五部分:VLM 视觉配置
VLM(Vision Language Model)视觉问答的配置。用户说"你看到了什么"时,豆包 LLM 输出 system_vlm 的 Function Call,端侧拿到后调用 SendVlmRequest(),发一个 HTTP POST 到这个 vlm.url。
vlm.url 指向的是内网部署的 VLM 服务(10.3.192.9:8089),不是火山公有云的服务。vlm.model 指定模型名。vlm.jpg_quality: 80 是摄像头帧压缩成 JPEG 的质量参数------质量太高传输慢,太低 VLM 看不清,80 是个折中值。vlm.timeout_ms: 5000 是 VLM 请求的超时,5 秒。
vlm.system_prompt 是发给 VLM 的系统提示词:"用简明扼要的中文描述内容,口语化的描述,不要带任何格式..."
第六部分:设备索引
video_device_index: 0 指定用第 0 号摄像头。audio_device_index: -1 表示用系统默认音频设备。
8.2 start_voice_chat_template.json --- 豆包的"脑子"
这个文件是整个端到端方案最重要的配置文件------它决定了豆包是谁、会什么、什么情况干什么。
什么时候用到的?在 joinRoom() → StartAgentAsync() → startAgent() → buildStartVoiceChatRequest() 这个调用链里:
cpp
// doubao_agent_manager.cc --- buildStartVoiceChatRequest()
std::string DoubaoAgentManager::buildStartVoiceChatRequest(...) {
// 1. 从磁盘读这个模板文件
std::string request_temple_file = m_config->start_request_temple_file;
// → "cfg/t1/start_voice_chat_template.json"
json request_temple_json;
std::ifstream stream(request_temple_file);
stream >> request_temple_json; // ← 读完整个 JSON
// 2. 填入动态值(模板里是空的,开机时动态填入)
request_temple_json["AppId"] = app_id;
request_temple_json["RoomId"] = room_id;
request_temple_json["TaskId"] = m_config->task_id;
// 3. 填入 AgentConfig
auto& agent = request_temple_json["AgentConfig"];
agent["UserId"] = m_config->agent_user_id;
agent["TargetUserId"].push_back(target_user_id);
// 4. 填入 S2S 鉴权
auto& s2s = request_temple_json["Config"]["S2SConfig"];
s2s["ProviderParams"]["app"]["appid"] = m_config->app_id;
s2s["ProviderParams"]["app"]["token"] = m_config->token;
// 5. 作为 HTTP Body POST 到火山
return request_temple_json.dump();
}
文件的大结构:
start_voice_chat_template.json
│
├── AppId, RoomId, TaskId ← 空占位符,运行时会填入
│
├── Config
│ ├── LLMConfig ← 【决策脑】给方舟大模型看的
│ │ ├── Mode: "ArkV3" ← 用火山方舟
│ │ ├── EndPointId ← 模型端点 ID
│ │ ├── SystemMessages ← ★ System Prompt(FC 的工具选择全在这里面)
│ │ ├── ThinkingType: disabled
│ │ └── Tools[] ← ★ FC 工具定义(JSON Schema)
│ │ ├── system_vlm ← VLM 视觉问答
│ │ └── robot_play_music ← 音乐播放
│ │
│ └── S2SConfig ← 【聊天脑】给 S2S 端到端模型看的
│ ├── OutputMode: 1 ← 混合编排
│ ├── Provider: volcano
│ └── ProviderParams
│ ├── app ← 鉴权(运行时会填入)
│ └── dialog
│ ├── bot_name: 元仔
│ ├── speaking_style ← 说话风格
│ ├── system_role ← ★ S2S 的人设 Prompt
│ ├── location ← 上海浦东
│ └── extra ← 联网搜索等附加功能
│ ├── model: 1.2.1.0
│ ├── enable_volc_websearch: true
│ └── enable_music: true
│
└── AgentConfig
├── TargetUserId ← 运行时会填入
├── WelcomeMessage: 你好我是元仔
├── Burst.Enable: true ← 允许打断
└── IdleTimeout: 10 ← 10 秒不说话进入空闲
为什么有两个 System Prompt?
因为混合编排模式下有两个模型在同时工作:
LLMConfig.SystemMessages → 给方舟大模型看(决定"要不要调工具")
内容:非常严格的规则,"仅当命中以下词汇才允许触发..."
风格:白名单+禁止列表,每个 FC 命令都有精确的触发词和禁止词
S2SConfig.system_role → 给 S2S 端到端模型看(决定"怎么说话")
内容:人设、说话风格、ASR 容错
风格:松散描述,"你是元仔,一个小朋友..."
两个 Prompt 必须分别维护的原因:方舟 LLM 需要严格的 FC 触发规则(否则会误触发),S2S 模型需要自然的人设描述(否则说话像机器人)。如果混在一起,要么 FC 准确率下降,要么对话质量下降。
SystemMessages 为什么这样设计?
最终版的 System Prompt 的风格是这样的:
markdown
# 指令机器人
你只能调用以下工具:
1. robot_play_music
2. system_vlm
除非明确命中下面规则,否则禁止调用任何 function。
# play
仅当用户明确包含"音乐播放"相关词时调用。
允许触发词:播放音乐、来首歌、来点歌、放首歌、听歌、想听歌...
否则禁止触发 play。
# pause
仅以下词允许触发:暂停、pause、暂停音乐
# 禁止规则
以下内容禁止调用任何工具:
需要、好的、可以、yes、ok、向前走、往前走、继续、下一步、上一页
# 最终规则
命中规则才允许调用 function。否则直接普通回复。
这种"白名单+禁止列表"的风格,是为了应对 FC 是黑盒这个现实。如果写的是描述式 Prompt("判断用户意图,选择合适的工具..."),LLM 可能自作主张------比如用户说"好的"时以为是同意播放音乐。白名单风格把触发条件精确到了具体词汇,禁止列表把最常见的误触发词("好的"、"可以"、"需要")全部封死。
Tools 里的 JSON Schema 是干什么的?
不是给人类看的,是给方舟 LLM 看的。LLM 读了这个 Schema 就知道调用工具时应该输出什么格式的 JSON。比如 robot_play_music 的 Schema 告诉 LLM:
json
{
"name": "robot_play_music",
"parameters": {
"command": { "enum": ["play", "pause", "resume", "stop", "prev", "next"] },
"music_name": { "type": "string", "description": "音乐名称" },
"music_pre_tip": { "type": "string", "description": "播报的TTS文本" }
},
"required": ["command"]
}
LLM 看到这个,就知道"如果用户要放音乐,输出一个 JSON,command 必须是这 6 个之一,music_name 可选,music_pre_tip 可选"。
和 doubao.json 的关系
doubao.json 里的 start_request_temple_file 字段指向的就是这个文件:
json
// doubao.json 里
"start_request_temple_file": "cfg/t1/start_voice_chat_template.json"
所以 doubao.json 是"管线配置"(连哪个服务器、用什么模型、音频参数),这个文件是"智能体配置"(智能体叫什么、会什么、怎么说话)。两个文件配合起来,豆包才知道自己是什么、怎么干活。
8.3 agent.yaml --- 新增的两个线程池
agent.yaml 是 AimRT 框架的模块配置文件,原来的内容不变,只追加了两个 executor:
yaml
- name: AgentModule/hds
type: asio_thread
options:
thread_num: 1
- name: AgentModule/event
type: asio_thread
options:
thread_num: 1
这两个什么时候用到?不是开机时直接读的,而是在 OnInitialize() 阶段,AimRT 框架解析 agent.yaml,创建对应的线程池。后面通过 executor 名字引用:
cpp
// module.cpp --- LoadOmniSDK 的时候
option_.controller_option.omnis_sdk_callback_thread_pool =
aimrte::ctx::init::Executor("omnis_sdk_callback_thread_pool");
// 类似的,业务代码里会通过名字引用 AgentModule/hds 和 AgentModule/event
-
AgentModule/hds:HDS(Hardware Device Service,硬件设备服务)的线程。HDS 是一个独立的组件,负责监控硬件状态(告警等),以固定频率(20 Hz)发布诊断信息。原来可能跟其他业务共用线程,现在独立出来,避免 HDS 的定时发布被其他任务阻塞。 -
AgentModule/event:事件处理专用线程。接入豆包后,云端 FC 事件流到达时需要一个独立的处理线程------如果跟主音频管线共用线程,FC 执行(比如调 VLM 服务、写磁盘文件)可能阻塞音频传输。
8.4 三个配置文件的关系
doubao.json
│
├── app_id, token → RTC 鉴权 + HTTP API 鉴权
├── audio_observer_config → 决定收哪些音频流
├── vlm → VLM 视觉服务的连接信息
│
└── start_request_temple_file ──→ start_voice_chat_template.json
│
├── LLMConfig → 方舟 LLM 的 System Prompt + FC 工具
└── S2SConfig → S2S 模型的 System Prompt + 语音参数
agent.yaml
└── AgentModule/hds, AgentModule/event → 独立线程池
简单记:
- doubao.json = 管线怎么连
- start_voice_chat_template.json = 智能体怎么做
- agent.yaml = 线程怎么分
9. 整个启动流程串一遍
机器人开机后,OnStart() 的执行顺序:
时间 →
① omnis_core_->Start()
└── 交互云先连上(WebSocket)
② StartBusiness()
└── 业务服务启动:GreetingService(迎宾)、GestureRecognition(手势识别)
③ controller_.WakeUp()
└── 发送唤醒信号,屏幕亮起
④ info_input_impl_->Start()
└── X2 硬件输入就绪(按键、传感器)
⑤ ┌──────────── 豆包初始化(新增)────────────┐
│
│ a. AppDataManager::load("cfg/t1/doubao.json")
│ └── 读 JSON,解析配置,填到 StuAppData 结构体
│
│ b. RTCVideoEngineWrapper::instance()->init()
│ └── 调 IRTCEngine::createRTCEngine() 创建引擎
│ └── 配置音频参数(采样率 16k、单声道)
│ └── 加载 Token
│
│ c. rtcWrapper->joinRoom()
│ └── TokenGenerator 生成入房 Token
│ └── createRTCRoom + joinRoom
│ └── publishStreamAudio → 开始推音频流
│ └── StartAgentAsync → HTTP POST StartVoiceChat → 云端创建智能体
│ └── 🔗 WebRTC 通道打通!
│
│ d. RegisterJpgEncoder(OpenCV 编码 lambda)
│ └── 把 JPEG 编码能力注入 RTC Wrapper
│
│ e. controller_.SetRtcWrapper(rtcWrapper)
│ └── Controller 拿到 RTC 指针
│ └── HandleVideoData 开始每帧推送给 RTC
│
│ f. RegisterAudioCallback(播放 lambda)
│ └── 豆包 TTS 音频 → output_audio_handler_->PlayAudioStream()
│
│ g. RegisterSkillCallback(SkillManager lambda)
│ └── 豆包 FC JSON → SkillManager::ExecuteSkillAsync()
│
│ h. 检查智能体状态 → isAgentEnabled && isAgentRunning
│
└──────────────────────────────────────────┘
⑥ input_audio_handler_->OnStart()
└── 麦克风最后才开,避免和 ByteRTC SDK 抢音频设备
10. 数据是怎么流的
用户说完一句话后,数据的流向:
麦克风
│
PortAudio 采集 PCM
│
┌──────┴──────┐
▼ ▼
OmniSDK 通路 ByteRTC 通路
(我们推音频) (SDK 自己采)
│ │
WebSocket WebRTC
│ │
我们的交互云 火山豆包云
│ │
┌─────────┴──────┐ │
│ │ │
NLU 动作 NLG 文本 │
│ │ │
▼ ▼ ▼
HandleAction HandleNlg TTS 音频 / FC JSON
Callback Result │
│ │ ┌───┴───┐
▼ ▼ ▼ ▼
Dispatch 直接返回 Audio onRoomBinary
SkillEvent (模板化) Callback MessageReceived
│ │ │ │
│ │ ▼ ▼
│ │ 播放TTS m_skillCallback
│ │ │
└────────┬───────┘ │
▼ ▼
SkillManager::ExecuteSkillAsync(nlu_json)
│
▼
Plugin 执行(动作/表情/TTS/移动)
│
▼
AimRT RPC → MC / TTS / 表情硬件模块
11. 一些设计上的选择
为什么包 Wrapper 不直接调 SDK
火山 SDK 以后可能会升级版本,两三年后可能整个方案都换掉。把 SDK 包在 Wrapper 里,改版本只需改 Wrapper 内部,外围不受影响。
配置放 JSON 不放代码里
开发阶段调参很频繁------换个模型、改个音色、调个 VAD 超时。如果在代码里就要改一次编译一次,放 JSON 里改完重启就行。
回调注入而不是 include
RTC Wrapper 是一个底层通信模块,它不应该 include OpenCV、不应该 include SkillManager。让它保持独立,需要的能力通过注册回调让 AgentModule 注入。这是依赖倒置。
atomic 指针连接 Controller 和 RTC
Controller 在主线程跑,RTC 回调在 SDK 线程跑。rtc_wrapper_ 用 std::atomic<RTCVideoEngineWrapper*>,配合 load(memory_order_acquire) 和 store(memory_order_release) 保证两边的读写不出问题。注入之前 Wrapper 是 nullptr,HandleVideoData 会跳过帧推送。
预缓冲 preroll
WebRTC 刚开始收到音频时,帧可能不完整。代码里做了一个状态机:Silent → Buffering → Streaming。缓冲期间把帧攒够 preroll_ms 毫秒,然后一次性扔给音频播放器,之后再逐帧播放。这样可以避免开头卡顿和爆音。