LLM 与 AI 语音助手接入
本文档介绍 AC79NN AIoT SDK 中 LLM(大语言模型)与 AI 语音助手接入的整体架构、核心服务、各平台接入方式与典型调用流程,帮助开发者理解并基于此能力构建语音交互产品。
Purpose and Scope
本页覆盖 apps/common/LLM 目录下的完整 LLM/AI 语音助手接入能力,包括:
- OneSDK AI Gateway(aigw):设备端 LLM 配置下发服务(
get_llm_config),负责从云端获取 LLM 服务的 URL 与 APIKey; - OneSDK Realtime Chat Agent:实时语音对话示例(
onesdk_realtime/chat_agent),展示端到端音频数据采集、Base64 编码与语音交互流程; - 各 AI 平台接入:讯飞 AIUI(
ifly_aiui)、百度 DuerOS(duer)、AITOY 端到端(aitoye2e)等; - 音频输入基础设施:
LLM/audio/audio_input.c为语音助手提供麦克风数据采集接口。
以下内容属于其他页面,不在本页展开:ASR(语音识别)算法本身(见 apps/common/asr)、网络协议栈(HTTP/MQTT)的底层实现、OneSDK 设备动态注册(iot/dynreg)的完整机制。本页仅引用它们作为依赖。
Overview
在 AC79NN 平台上,AI 语音助手是一个端到端的实时语音对话链路:设备端通过麦克风采集用户语音 → 将音频数据(Base64 编码)发送到云端 LLM 服务 → 云端返回文本/音频回复 → 设备端播报。整个链路的关键前提是设备能够安全地获取 LLM 服务的接入凭证(URL + APIKey),而不是把密钥烧录在固件中。
为此,SDK 在 apps/common/LLM/onesdk/src/aigw/llm.c 中实现了 AIGW(AI Gateway)配置获取服务:设备基于已注册的 OneSDK 身份(instance_id、product_key、device_name、device_secret)向云端 GetLLMConfig 接口发起带 HMAC-SHA256 签名的 HTTP POST 请求,云端返回加密的 APIKey 与 URL,设备端用 device_secret 做 AES 解密后获得真正的 LLM 接入凭证。这样固件中只需保存设备密钥,即使固件泄露也不会直接暴露 LLM 的 APIKey。
在业务层,SDK 同时提供了多种语音助手方案的接入示例:
| 方案 | 目录 | 定位 |
|---|---|---|
| OneSDK Realtime Chat Agent | onesdk/examples/onesdk_realtime/chat_agent | 基于火山方舟 OneSDK 的实时语音对话(推荐) |
| 讯飞 AIUI | ifly_aiui | 讯飞 AIUI 语音交互方案 |
| 百度 DuerOS | duer/intelligent_duer | 百度 DuerOS 智能语音助手 |
| AITOY 端到端 | aitoye2e | AITOY 端到端语音模型示例 |
Architecture
下图展示了 LLM 与 AI 语音助手接入的整体架构与数据流:
flowchart TD
subgraph sg_Device["设备端 (AC79NN)"]
Mic["麦克风采集<br/>LLM/audio/audio_input.c"]
App["应用层<br/>onesdk_realtime/chat_agent<br/>realtime_demo.c"]
AIGW["AIGW 配置服务<br/>onesdk/src/aigw/llm.c"]
Base64["Base64 编解码<br/>realtime_demo.c 内实现"]
Play["音频播报"]
end
subgraph sg_Cloud["云端"]
IOT["OneSDK IoT 平台<br/>GetLLMConfig"]
LLMSvc["LLM 语音服务<br/>Realtime API"]
Vendor["第三方平台<br/>AIUI / DuerOS"]
end
subgraph sg_Auth["安全基础设施"]
HMAC["HMAC-SHA256 签名"]
AES["AES 解密 APIKey"]
end
App --> AIGW
AIGW --> HMAC
AIGW -->|"HTTP POST<br/>签名请求"| IOT
IOT -->|"加密 APIKey + URL"| AIGW
AIGW --> AES
AES -->|"明文 URL/APIKey"| App
Mic -->|"PCM 音频"| App
App --> Base64
Base64 -->|"Base64 音频帧"| LLMSvc
LLMSvc -->|"文本/音频回复"| App
App --> Play
App -.-> Vendor
各组件职责:
- AIGW 配置服务(
llm.c):封装设备动态注册、签名生成、HTTP 请求、响应解析与 APIKey 解密,对外只暴露get_llm_config()与aigw_llm_config_destroy()两个接口; - 音频输入(
audio_input.c):为语音助手提供统一的麦克风采集抽象,屏蔽不同音频硬件的差异; - Realtime Chat Agent(
realtime_demo.c):把采集到的音频帧 Base64 编码后发送给 LLM Realtime 服务,并接收回复; - 安全基础设施:HMAC-SHA256 保证请求可被云端校验身份,AES 保证 APIKey 在传输与固件中不落明文。
核心实现:AIGW LLM 配置服务
数据模型与错误码
llm.h 定义了整个 LLM 接入的错误码枚举与配置结构体:
typedef enum {
LLM_OK = 0, // 成功
LLM_ERR_BASE = -100, // 错误码基准值
// 具体错误类型
LLM_ERR_ALLOC_FAILED, // 内存分配失败
LLM_ERR_DEV_REG_FAILED, // 设备注册失败
LLM_ERR_SIGN_FAILED, // 签名生成失败
LLM_ERR_INVALID_CTX, // 上下文无效
LLM_ERR_NETWORK_FAIL, // 网络通信失败
LLM_ERR_PARSE_FAILED
} llm_error_t;
typedef struct {
llm_error_t err_code;
char *url;
char *api_key;
} aigw_llm_config_t;
Source: llm.h
设计意图:错误码从 -100 起步,与 OneSDK 其他组件错误码错开,避免混淆;aigw_llm_config_t 中 url 与 api_key 为堆内存指针,因此配套了显式的销毁函数 aigw_llm_config_destroy(),由调用方负责生命周期管理。
get_llm_config:配置获取主流程
get_llm_config() 是整个 LLM 接入的入口,实现于 llm.c,其完整流程为:
- 生成随机数与时间戳:
random_num()+unix_timestamp(),作为请求签名参数的一部分,防止重放攻击; - 初始化 AWS 公共库:
aws_common_library_init(),为后续 JSON/字节缓冲操作准备分配器; - 构建动态注册参数:填充
iot_dynamic_register_basic_param(instance_id、timestamp、random_num、product_key、device_name、auth_type); - 按需动态注册:若
device_secret为空,先调用dynamic_register()完成设备注册,失败则返回LLM_ERR_DEV_REG_FAILED; - 强制 auth_type = 0:代码注释明确"llm api auth_type must be 0",即 LLM API 只接受一型密钥认证;
- HMAC-SHA256 签名:
iot_hmac_sha256_encrypt()用device_secret对注册参数生成签名; - 组装 POST 请求体:JSON 中包含
InstanceID、product_key、device_name、random_num、timestamp、signature; - 构造请求 URL:
http_host+ 固定路径/2021-12-14/GetLLMConfig,查询参数Action=GetLLMConfig&Version=2021-12-14; - 发起 HTTP POST:
new_http_ctx()→http_ctx_set_url()→http_ctx_set_method(HTTP_POST)→ 设置Content-Type: application/json→http_request(); - 解析响应:调用静态函数
parse_llm_config(),成功则填充out_config,网络层失败返回LLM_ERR_NETWORK_FAIL; - 清理:释放 JSON、body、http_ctx 等资源。
static int parse_llm_config(struct aws_allocator *allocator, const char *device_secret,
const http_response_t *response, aigw_llm_config_t *out_config)
{
if (out_config == NULL) {
return LLM_ERR_ALLOC_FAILED;
}
struct aws_json_value *response_json = aws_json_value_new_from_string(allocator,
aws_byte_cursor_from_c_str(response->response_body));
if (!response_json) {
goto error_cleanup;
}
struct aws_json_value *result_json = aws_json_value_get_from_object(response_json,
aws_byte_cursor_from_c_str("Result"));
if (!result_json) {
goto error_cleanup;
}
char *key = aws_json_get_str(allocator, result_json, "APIKey");
if (!key) {
goto error_cleanup;
}
char *api_key = aes_decode(allocator, device_secret, key, false);
free(key); // 修正内存释放方式
if (!api_key) {
goto error_cleanup;
}
out_config->api_key = api_key;
char *url = aws_json_get_str(allocator, result_json, "URL");
if (!url) {
goto error_cleanup;
}
out_config->url = strdup(url);
free(url);
// 修正JSON对象释放方式
aws_json_value_destroy(response_json);
return LLM_OK;
error_cleanup:
if (response_json) {
aws_json_value_destroy(response_json);
}
return LLM_ERR_PARSE_FAILED;
}
Source: llm.c
关键安全设计:云端返回的 APIKey 是密文,设备端必须用 device_secret 通过 aes_decode() 解密才能获得真正的 Key;URL 字段则明文下发。这样固件中只有设备密钥,LLM 服务密钥的泄露面被压缩到"已注册设备"本身。解析使用 goto error_cleanup 统一错误出口,所有失败路径都返回 LLM_ERR_PARSE_FAILED 并释放 JSON 对象。
配置销毁
void aigw_llm_config_destroy(aigw_llm_config_t *config)
{
if (!config) {
return;
}
if (config->api_key) {
free(config->api_key);
config->api_key = NULL;
}
if (config->url) {
free(config->url);
config->url = NULL;
}
free(config);
config = NULL;
}
Source: llm.c
设计意图:api_key 是敏感凭证,使用 free() 释放而非延迟回收;局部置 NULL 防止悬垂指针。该函数必须是调用方在使用完配置后显式调用,属于典型的"谁申请谁释放"所有权模型。
核心实现:OneSDK Realtime Chat Agent
apps/common/LLM/onesdk/examples/onesdk_realtime/chat_agent/realtime_demo.c 是实时语音对话的完整示例,包含:
- 实例配置宏:
SAMPLE_HTTP_HOST(线上实例iot-cn-shanghai.iot.volces.com)、SAMPLE_INSTANCE_ID、SAMPLE_MQTT_HOST、SAMPLE_DEVICE_NAME、SAMPLE_DEVICE_SECRET、SAMPLE_PRODUCT_KEY、SAMPLE_PRODUCT_SECRET,接入时替换为真实产品参数; - GlobalSign Root CA 证书:内置
global_sign_root_ca[],用于 TLS 校验证书链; - Base64 编解码工具:基于 mbedtls 实现两遍式(先查询长度再实际编码)的
base64_encode()/base64_decode(); - 音频测试数据:
data[]/data2[]数组存放 PCM 音频的 Base64 字符串,用于离线验证语音链路。
int base64_encode(const unsigned char *input, size_t input_len, char **output, size_t *output_len)
{
// 参数校验
if (!input || input_len == 0 || !output || !output_len) {
return MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL;
}
// 第一次调用:获取所需缓冲区大小
size_t olen;
int ret = mbedtls_base64_encode(NULL, 0, &olen, input, input_len);
if (ret != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) {
return ret;
}
// 分配内存(包括终止符)
*output = (char *)malloc(olen + 1);
if (!*output) {
return MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL;
}
// 第二次调用:实际编码
if ((ret = mbedtls_base64_encode((unsigned char *)*output, olen,
output_len, input, input_len)) != 0) {
free(*output);
*output = NULL;
return ret;
}
// 添加终止符
(*output)[*output_len] = '\0';
return 0;
}
Source: realtime_demo.c
设计意图:两遍式编码避免调用方预先知道输出长度;malloc(olen + 1) 多分配一个字节存放 '\0' 终止符,使输出可直接当作 C 字符串使用。base64_decode() 则对称地先去除末尾终止符再解码。语音数据走 Base64 是为了让二进制 PCM 音频能够嵌入 JSON/文本协议中传输。
第三方 AI 平台接入
SDK 在 apps/common/LLM 下按平台分目录组织接入代码:
| 目录 | 说明 | 关键文件 |
|---|---|---|
ifly_aiui | 讯飞 AIUI:ifly_aiui_demo.c(示例)、ifly_aiui_user.c(用户配置)、ifly_model_parse.c(模型解析) | ifly_aiui_demo.c |
duer | 百度 DuerOS:intelligent_duer(认证算法 duer_auth_algorithm.c、HTTP 请求 duer_http_req.c、JSON 解析 duer_json_parse.c、录音 duer_record.c、Socket duer_socket.c);my_platform(自有平台 HTTP API 封装) | duer_http_req.c、duer_auth_algorithm.c |
aitoye2e | AITOY 端到端语音模型示例 | e2e_demo.c |
extra | 附加工具(如 G.711 编解码 g711.c,用于音频编码转换) | g711.c |
audio | 音频输入抽象,供各语音方案复用 | audio_input.c |
各平台接入共享同一套基础能力:设备身份注册(OneSDK iot_basic)、HTTP/TLS 传输(protocols/http)、音频采集(LLM/audio)。差异主要体现在认证方式(讯飞 AppID/APIKey、DuerOS OAuth 算法、OneSDK HMAC 签名)与协议格式(JSON 请求、WebSocket 长连接等)上。
核心流程
LLM 配置获取时序
sequenceDiagram
participant App as 应用层 (chat_agent)
participant AIGW as aigw/llm.c
participant Reg as iot/dynreg
participant HTTP as protocols/http
participant Cloud as OneSDK 云端 GetLLMConfig
App->>AIGW: get_llm_config(iot_basic_ctx, &out_config)
activate AIGW
AIGW->>AIGW: 生成 random_num / timestamp
alt device_secret 为空
AIGW->>Reg: dynamic_register(iot_basic_ctx)
Reg-->>AIGW: 设备注册结果
end
AIGW->>AIGW: HMAC-SHA256 签名 (auth_type=0)
AIGW->>HTTP: POST /2021-12-14/GetLLMConfig<br/>body: InstanceID/product_key/device_name/random/timestamp/signature
activate HTTP
HTTP->>Cloud: HTTPS 请求
Cloud-->>HTTP: Result{APIKey(密文), URL}
deactivate HTTP
AIGW->>AIGW: aes_decode(device_secret, APIKey)
AIGW-->>App: LLM_OK + out_config{url, api_key}
deactivate AIGW
App->>App: aigw_llm_config_destroy(&config)
实时语音对话数据流
flowchart LR
Mic["麦克风 PCM 采集"] --> Enc["base64_encode<br/>(mbedtls 两遍式)"]
Enc --> Send["发送音频帧到 LLM Realtime"]
Send --> Resp["接收文本/音频回复"]
Resp --> Dec["base64_decode"]
Dec --> Play["音频播报"]
Resp --> Text["文本展示/意图执行"]
实时对话链路的特点:音频帧是分块连续发送的(流式),每帧独立 Base64 编码后随请求体上行;回复同样以流式分块到达。这与配置获取的一次性 HTTP 请求(上节)在时序上有本质区别——配置获取是"先认证、再使用"的前置步骤,语音对话则是长连接上的持续交互。
使用示例
获取 LLM 接入配置
#include "llm.h"
#include "iot_basic.h"
// iot_basic_ctx 由 OneSDK 初始化(包含 instance_id/product_key/device_name/device_secret 等)
aigw_llm_config_t *llm_cfg = malloc(sizeof(aigw_llm_config_t));
int ret = get_llm_config(iot_basic_ctx, llm_cfg);
if (ret == LLM_OK) {
printf("LLM url=%s, api_key=%s\n", llm_cfg->url, llm_cfg->api_key);
// 使用 url/api_key 建立 LLM Realtime 连接...
} else {
printf("get_llm_config failed: %d\n", ret);
}
aigw_llm_config_destroy(llm_cfg);
音频帧 Base64 编码(语音上行)
// input: 麦克风采集的 PCM 数据; input_len: 帧长度
char *b64 = NULL;
size_t b64_len = 0;
if (base64_encode((const unsigned char *)pcm_frame, frame_len, &b64, &b64_len) == 0) {
// b64 可直接嵌入 JSON 请求体发送给 LLM Realtime 服务
send_audio_frame(b64, b64_len);
free(b64);
}
Source: realtime_demo.c
示例接入参数配置
// 线上实例
#define SAMPLE_HTTP_HOST "iot-cn-shanghai.iot.volces.com"
#define SAMPLE_INSTANCE_ID "***"
#define SAMPLE_MQTT_HOST "***"
#define SAMPLE_DEVICE_NAME "***"
#define SAMPLE_DEVICE_SECRET "***"
#define SAMPLE_PRODUCT_KEY "***"
#define SAMPLE_PRODUCT_SECRET "***"
Source: realtime_demo.c
配置选项
| 配置项 | 来源 | 类型 | 说明 |
|---|---|---|---|
SAMPLE_HTTP_HOST | realtime_demo.c | 字符串宏 | 云端 IoT 平台主机名,用于拼接 GetLLMConfig 请求 URL |
SAMPLE_INSTANCE_ID | realtime_demo.c | 字符串宏 | OneSDK 实例 ID,设备注册与请求签名的必填参数 |
SAMPLE_DEVICE_NAME | realtime_demo.c | 字符串宏 | 设备名称 |
SAMPLE_DEVICE_SECRET | realtime_demo.c | 字符串宏 | 设备密钥,用于 HMAC 签名与 APIKey AES 解密;为空时触发 dynamic_register() |
SAMPLE_PRODUCT_KEY | realtime_demo.c | 字符串宏 | 产品 Key |
SAMPLE_PRODUCT_SECRET | realtime_demo.c | 字符串宏 | 产品密钥(动态注册用) |
iot_basic_ctx->config->auth_type | iot_basic 配置 | 整数 | 认证类型;LLM API 强制要求为 0 |
GET_LLM_CONFIG_PATH | llm.c | 常量字符串 | /2021-12-14/GetLLMConfig,云端接口路径,随 API 版本演进 |
API_VERSION_QUERY_PARAM | llm.c | 常量字符串 | Version=2021-12-14,API 版本查询参数 |
API 参考
int get_llm_config(iot_basic_ctx_t *iot_basic_ctx, aigw_llm_config_t *out_config)
从云端获取 LLM 服务配置(URL 与解密后的 APIKey)。
参数:
iot_basic_ctx(iot_basic_ctx_t *):OneSDK 基础上下文,必须已初始化,包含config->instance_id、product_key、device_name、device_secret、auth_type、http_host等字段;out_config(aigw_llm_config_t *):输出参数,成功时填充url与api_key(均为堆内存,需调用aigw_llm_config_destroy释放)。
返回:
LLM_OK(0):成功;LLM_ERR_DEV_REG_FAILED:device_secret为空且动态注册失败;LLM_ERR_NETWORK_FAIL:HTTP 请求无响应;LLM_ERR_PARSE_FAILED:响应 JSON 缺失Result/APIKey/URL或 AES 解密失败;LLM_ERR_ALLOC_FAILED:out_config为 NULL 或内存分配失败。
实现要点: 见 llm.c。
void aigw_llm_config_destroy(aigw_llm_config_t *config)
释放 aigw_llm_config_t 及其内部 url、api_key 内存,并将指针置 NULL。见 llm.c。
int base64_encode(const unsigned char *input, size_t input_len, char **output, size_t *output_len)
将二进制音频数据编码为 Base64 字符串(输出带 '\0' 终止符)。返回 0 成功;参数非法或 mbedtls 失败时返回 MBEDTLS 错误码。见 realtime_demo.c。
int base64_decode(const char *input, size_t input_len, unsigned char **output, size_t *output_len)
将 Base64 字符串解码为二进制 PCM 数据;自动剔除末尾 '\0'。返回 0 成功。见 realtime_demo.c。
失败模式与边界情况
设备未注册
当 iot_basic_ctx->config->device_secret == NULL 时,get_llm_config() 会先调用 dynamic_register() 完成设备注册。若注册失败(如网络不通、产品密钥错误),直接返回 LLM_ERR_DEV_REG_FAILED,调用方应重试或上报错误。注意:注册成功后 device_secret 会被写入上下文,后续调用不会再重复注册。
APIKey 解密失败
parse_llm_config() 中对 APIKey 的解析使用 aes_decode(allocator, device_secret, key, false)。如果云端返回的密文与设备密钥不匹配(例如设备在云端被重置、密钥轮换),解密返回 NULL,函数走 error_cleanup 返回 LLM_ERR_PARSE_FAILED。此时固件中不会残留任何部分解析的配置。
HTTP 层失败
http_request() 返回 NULL(超时、DNS 失败、TLS 握手失败等)时返回 LLM_ERR_NETWORK_FAIL。源码未对 HTTP 做重试,重试策略由上层应用决定——典型的做法是短退避重试 2-3 次后提示用户"网络不可用"。
JSON 解析边界
- 响应体不是合法 JSON →
aws_json_value_new_from_string失败 →LLM_ERR_PARSE_FAILED; - 缺少
Result节点、APIKey或URL字段 → 各自goto error_cleanup; - 所有失败路径统一销毁
response_json,避免内存泄漏; out_config == NULL时返回LLM_ERR_ALLOC_FAILED(语义上为参数错误,但复用该错误码)。
Base64 编解码边界
input == NULL || input_len == 0直接返回错误,防止空指针解引用;- 解码时若输入恰好以
'\0'结尾则自动减长,兼容"带终止符的字符串"与"原始长度"两种调用习惯; - 编码输出的
'\0'终止符不计入output_len,调用方拼接 JSON 时需自行处理。
并发与资源管理
- 单线程模型:
get_llm_config()内部全程使用调用方线程栈上或显式分配的资源,无全局可变状态,天然可重入;但random_num()与unix_timestamp()依赖系统时钟与随机源,在同一时刻并发调用会生成相同时间戳——不支持并发调用同一上下文,调用方应串行化配置获取流程; - 内存所有权:
url/api_key由malloc/strdup分配,必须配对free(通过aigw_llm_config_destroy);示例中base64_encode的输出*output同样要求调用方free; - 安全擦除:签名串与
body_str使用aws_string_destroy_secure()销毁(清零后释放),降低敏感信息残留在堆中的风险;api_key释放使用普通free(),如需更高安全等级可替换为安全清零版本。
性能与运维注意
- 请求开销:
GetLLMConfig每次调用包含 1 次 HTTPS 往返 + HMAC 签名 + AES 解密,建议只在首次启动或配置失效时调用一次并缓存结果,避免频繁请求; - TLS 校验:
realtime_demo.c内置 GlobalSign Root CA,生产环境应使用与实例匹配的根证书;llm.c中 URL 协议部分被注释掉,默认直接使用http_host拼接,若需要 HTTPS 需启用对应配置; - 音频链路:Base64 编码会使数据膨胀约 33%,在低带宽网络下需评估上行带宽;建议按帧编码、流式发送,避免一次性缓存整段音频;
- 调试手段:
llm.c中保留了printf("llm request body: %s\n", content)与响应体打印,可临时开启定位签名/解析问题,量产固件应关闭。
扩展点
- 新增云端 API 版本:修改
GET_LLM_CONFIG_PATH与API_VERSION_QUERY_PARAM宏即可适配新的接口版本,无需改动调用方; - 接入新 LLM 平台:参照
ifly_aiui、duer、aitoye2e的目录结构,在apps/common/LLM/下新增平台目录,复用LLM/audio/audio_input.c的采集抽象与 OneSDK 的注册/HTTP 能力; - 替换认证方式:
get_llm_config()内的 HMAC 签名与 AES 解密是内聚的两步,可抽成独立的"凭证提供器"接口,便于对接私有化部署或自建网关; - 音频格式扩展:
extra/g711.c提供 G.711 编解码,可在此扩展更多编码(如 Opus、AAC)以适配不同云端的音频格式要求; - 重试与缓存:建议在应用层包一层"配置缓存 + 退避重试",这是当前源码刻意留给上层的策略空间。
相关链接
相关页面:ASR 语音识别细节见"语音识别"目录;OneSDK 设备注册与 MQTT 链路见"设备接入"目录;HTTP/TLS 协议栈见"网络协议"目录。