阅读视图
Bluetooth常见问题
iOS 蓝牙开发是面试中经常考察的知识点,尤其是涉及硬件交互、IoT 等领域的岗位。下面我为你整理了常见的面试问题,分为基础概念、核心流程、进阶问题和实战问题几大类。
一、基础概念
1. 蓝牙有几种技术标准?iOS 开发主要用哪种?
- 经典蓝牙 (Bluetooth Classic): 传输速率高、功耗大,主要用于音频传输、文件传输等场景。iOS 对经典蓝牙的设备间通信支持有限(主要通过 MFI 认证)。
- 低功耗蓝牙 (Bluetooth Low Energy, BLE): 功耗低、数据量小,主要用于设备状态同步、传感器数据采集等。iOS 蓝牙开发主要围绕 BLE。
2. iOS 中处理蓝牙的核心框架是哪个?
- CoreBluetooth.framework。
- 它提供了与 BLE 设备交互的所有必要类和方法。
3. 解释 BLE 中的中心设备 (Central) 和外设 (Peripheral) 角色
- 外设 (Peripheral): 广播数据的设备,例如智能手环、心率监测器。它持有数据。
- 中心设备 (Central): 扫描并连接外设的设备,例如 iPhone。它消费数据。
-
在 iOS 开发中,我们的 App 通常作为 Central 去连接其他 BLE 设备。 但 iOS 设备也可以作为 Peripheral(通过
CBPeripheralManager
)。
二、核心流程与 API
4. 描述一个完整的 Central 端连接和数据交互流程
这是最核心的问题,几乎必问。
-
创建中心管理器
centralManager = CBCentralManager(delegate: self, queue: nil)
-
监听蓝牙状态 (CBCentralManagerDelegate)
func centralManagerDidUpdateState(_ central: CBCentralManager) { if central.state == .poweredOn { // 蓝牙已开启,开始扫描 central.scanForPeripherals(withServices: nil, options: nil) } }
-
发现外设
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { // 检查外设名称或广播数据,找到目标设备 if peripheral.name == "MyDevice" { self.targetPeripheral = peripheral central.stopScan() central.connect(peripheral, options: nil) } }
-
连接外设
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { // 连接成功,设置 Peripheral 的代理并开始发现服务 peripheral.delegate = self peripheral.discoverServices(nil) // 传入 nil 发现所有服务 }
-
发现服务 (CBPeripheralDelegate)
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { if let services = peripheral.services { for service in services { // 根据目标服务的 UUID 进行过滤 if service.uuid == CBUUID(string: "180D") { // 心率服务 peripheral.discoverCharacteristics(nil, for: service) } } } }
-
发现特征
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { if let characteristics = service.characteristics { for characteristic in characteristics { // 根据特征的属性进行操作 if characteristic.properties.contains(.notify) { peripheral.setNotifyValue(true, for: characteristic) } if characteristic.properties.contains(.read) { peripheral.readValue(for: characteristic) } // 如果需要写入 // if characteristic.properties.contains(.write) { ... } } } }
-
数据交互
-
读取数据:
peripheral.readValue(for: characteristic)
然后在didUpdateValueFor
回调中获取数据。 -
订阅通知:
peripheral.setNotifyValue(true, for: characteristic)
数据更新时,会在didUpdateValueFor
回调中收到。 -
写入数据:
peripheral.writeValue(data, for: characteristic, type: .withResponse)
(或.withoutResponse
)
-
读取数据:
-
断开连接
centralManager.cancelPeripheralConnection(peripheral)
5. 解释 BLE 的 GATT 结构
- GATT (Generic Attribute Profile): 定义了 BLE 设备间数据传输的格式和结构。
-
层级关系: Device -> Service -> Characteristic -> Descriptor
- Service (服务): 一个设备提供的特定功能,例如电池服务、设备信息服务。用 UUID 标识。
- Characteristic (特征): 服务下的具体数据点,是数据交互的真正载体。例如电池电量、心率测量值。它有自己的 UUID、值和属性(读、写、通知等)。
- Descriptor (描述符): 对 Characteristic 的额外描述,例如用户描述、特征格式等。
6. CBCharacteristic
的 properties
有哪些?各自含义是什么?
-
.read
: 可读 -
.write
: 可写(需要响应) -
.writeWithoutResponse
: 可写(不需要响应,速度快但不可靠) -
.notify
: 可订阅(外设主动推送数据,无确认) -
.indicate
: 可指示(外设主动推送数据,有确认,更可靠) - 等等。在操作一个特征前,必须检查其属性。
三、进阶问题
7. 后台模式下的蓝牙处理
-
配置: 在
Capabilities
中开启Background Modes
并勾选Uses Bluetooth LE accessories
。 -
限制:
- 扫描时需要使用 Service UUIDs(
scanForPeripherals(withServices: [CBUUID], options: ...)
),否则在后台扫描不到设备。 - App 在后台或被挂起时,所有蓝牙事件都会在后台队列中被缓存,当 App 回到前台时会一并交付。
- 扫描时需要使用 Service UUIDs(
-
状态保存与恢复: 通过实现
restoreIdentifier
和centralManager(_:willRestoreState:)
方法,可以让系统在 App 被杀死后重新启动时恢复蓝牙连接和状态。
8. 如何保证蓝牙连接的稳定性和重连机制?
-
监听断开事件: 实现
centralManager(_:didDisconnectPeripheral:error:)
代理方法。 - 实现重连逻辑: 在断开回调中,根据错误码和业务逻辑进行重试连接。
-
连接超时处理:
CoreBluetooth
没有原生超时,需要自己用DispatchWorkItem
或Timer
实现。 - 错误处理: 妥善处理常见的错误,如连接失败、服务发现失败等。
9. 蓝牙配对和绑定 (Bonding) 的区别?
- 配对 (Pairing): 一个一次性的过程,用于交换密钥、认证身份,建立安全的连接。
- 绑定 (Bonding): 在配对成功后,将交换的密钥(LTK, Long-Term Key)存储起来,以便后续重新连接时无需再次配对。在 iOS 上,这个过程由系统自动管理。
10. 作为 Peripheral 端 (CBPeripheralManager
) 需要做什么?
- 创建
CBPeripheralManager
。 - 设置服务和特征(
CBMutableService
和CBMutableCharacteristic
)。 - 发布服务到本地数据库。
- 开始广播。
- 处理 Central 的订阅、读请求和写请求。
四、实战与经验
11. 你在项目中遇到的蓝牙难点是什么?如何解决的?
这是一个开放性问题,考察实际经验。可能的答案:
- 连接不稳定: 实现了指数退避算法的重连机制。
- 数据分包与组包: BLE 单次传输数据量有限(通常是 20 字节),需要设计协议来处理长数据。
-
多设备连接管理: 使用字典或数组管理多个
CBPeripheral
实例。 - 不同厂商设备兼容性: 处理非标准的 UUID 或不符合规范的 GATT 结构。
12. 如何调试蓝牙问题?
- 使用 LightBlue、nRF Connect 等第三方 App 模拟 Peripheral 或扫描设备,验证硬件本身是否正常。
- 在 Xcode 中查看
CoreBluetooth
的日志(有时需要额外的系统日志工具)。 - 检查所有的 Delegate 回调,特别是错误回调。
- 使用
Packet Logger
(需要苹果开发者账号)进行底层 HCI 日志抓取,这是最强大的调试手段。
13. 了解 Bluetooth 5.0 的新特性吗?
- 2M PHY: 更高的传输速率。
- LE Audio: 新一代蓝牙音频标准。
- Long Range: 更长的传输距离。
- Advertising Extensions: 更强大的广播数据能力。
- 注意: 这些特性需要手机硬件和外围设备同时支持,并且 iOS API 可能对部分特性有版本要求。
ChatGPT流式输出完全解析之SSE
最近在面试,在整理之前做过的项目,整理的过程中我会整理相关技术栈的实现,本篇是任何对话式ai应用都会遇到的流式输出协议的其中之一SSE,chatpgt用的就是这个,它是单向的,服务端向客户端推送
我用langChain做了一个案例,大家可以clone下来看下具体的实现,不过大多数的ai应用中还是会选择socket的,因为需要双向通信,但是SSE相比socket而言没那么重,写简单项目还是很方便的,比如智能对话助手什么的
项目代码Learn-LLM
📡 SSE 核心原理
1.1 什么是 SSE?
Server-Sent Events (SSE) 是 HTML5 标准的一项服务端推送技术,允许服务器主动向客户端推送数据。
核心特点
✅ 单向通信 - 服务器 → 客户端(推送)
✅ 基于 HTTP - 无需额外协议支持
✅ 持久连接 - 长连接,持续推送
✅ 文本格式 - 简单的文本协议
✅ 自动重连 - 浏览器内置重连机制
✅ 事件驱动 - 支持自定义事件类型
技术优势
特性 | SSE | WebSocket | 长轮询 |
---|---|---|---|
通信方向 | 单向(服务器→客户端) | 双向 | 单向 |
协议 | HTTP | WebSocket | HTTP |
自动重连 | ✅ 内置 | ❌ 需手动实现 | ❌ 需手动实现 |
复杂度 | 简单 | 中等 | 简单 |
适用场景 | 推送通知、流式输出 | 实时聊天、游戏 | 简单轮询 |
🔧 SSE 实现详解
2.1 前端实现(EventSource API)
// 1. 创建 SSE 连接
const eventSource = new EventSource('/api/streaming/sse?message=hello');
// 2. 监听默认消息
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('收到数据:', data);
};
// 3. 监听自定义事件
eventSource.addEventListener('status', (event) => {
console.log('状态更新:', event.data);
});
eventSource.addEventListener('progress', (event) => {
console.log('进度更新:', event.data);
});
eventSource.addEventListener('complete', (event) => {
console.log('完成:', event.data);
eventSource.close(); // 关闭连接
});
// 4. 连接生命周期
eventSource.onopen = () => {
console.log('✅ SSE 连接已建立');
};
eventSource.onerror = (error) => {
console.error('❌ SSE 错误:', error);
// 浏览器会自动尝试重连
};
// 5. 手动关闭连接
eventSource.close();
EventSource 状态
EventSource.CONNECTING(0); // 连接中
EventSource.OPEN(1); // 已连接
EventSource.CLOSED(2); // 已关闭
// 检查状态
if (eventSource.readyState === EventSource.OPEN) {
console.log('连接正常');
}
2.2 后端实现(Next.js)
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const encoder = new TextEncoder();
const readable = new ReadableStream({
async start(controller) {
try {
// 发送基本消息
controller.enqueue(
encoder.encode('data: {"content": "Hello World"}\n\n')
);
// 发送自定义事件
controller.enqueue(encoder.encode('event: status\ndata: 处理中\n\n'));
// 发送进度事件
controller.enqueue(encoder.encode('event: progress\ndata: 50%\n\n'));
// 发送完成事件
controller.enqueue(
encoder.encode('event: complete\ndata: 处理完成\n\n')
);
// 可选:主动关闭
// controller.close();
} catch (error) {
controller.error(error);
}
},
cancel(reason) {
console.log('客户端关闭连接:', reason);
},
});
return new Response(readable, {
headers: {
'Content-Type': 'text/event-stream', // ← SSE 的核心标识
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*',
},
});
}
2.3 SSE 数据格式规范
# 基本消息格式
data: Hello World\n\n
# 多行数据
data: 第一行\n
data: 第二行\n\n
# 自定义事件
event: notification\n
data: 新消息\n\n
# 带 ID 的消息(用于断线重连)
id: 123\n
data: 可重连的消息\n\n
# 设置重连时间(毫秒)
retry: 3000\n\n
# 重要规则:
# 1. 每个消息必须以两个换行符结尾 \n\n
# 2. data 字段可以多行
# 3. event 字段定义自定义事件类型
# 4. id 字段用于断线重连时的位置恢复
2.4 ChatGPT 流式对话实现
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const userMessage = searchParams.get('message') || 'Hello';
const encoder = new TextEncoder();
const readable = new ReadableStream({
async start(controller) {
// 初始化 LangChain ChatOpenAI
const llm = new ChatOpenAI({
openAIApiKey: process.env.OPEN_API_KEY,
modelName: 'gpt-3.5-turbo',
temperature: 0.7,
streaming: true, // ← 开启流式输出
});
// 创建处理链
const chain = chatPrompt.pipe(llm).pipe(new StringOutputParser());
// 开始流式调用
const stream = await chain.stream({ userMessage });
// 逐块发送
for await (const chunk of stream) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ content: chunk })}\n\n`)
);
}
// 发送完成事件
controller.enqueue(
encoder.encode('event: complete\ndata: AI对话完成\n\n')
);
},
});
return new Response(readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
🏗️ 技术栈解析
┌─────────────────────────────────────────┐
│ SSE (Server-Sent Events) │ ← 应用层协议(HTML5 标准)
├─────────────────────────────────────────┤
│ 前端: EventSource API │ ← SSE 专属 API
│ 格式: data:, event:, id:, retry: │ ← SSE 专属格式
│ 头部: text/event-stream │ ← SSE 专属标识
└─────────────────────────────────────────┘
↓ 使用(但不专属)
┌─────────────────────────────────────────┐
│ Web Streams API (通用) │ ← 通用流处理 API
│ - ReadableStream │
│ - WritableStream │
│ - TransformStream │
│ - Controller (enqueue, close, error) │
└─────────────────────────────────────────┘
↓ 使用
┌─────────────────────────────────────────┐
│ Web Encoding API (通用) │ ← 通用编码 API
│ - TextEncoder (字符串 → 字节) │
│ - TextDecoder (字节 → 字符串) │
└─────────────────────────────────────────┘
↓ 基于
┌─────────────────────────────────────────┐
│ HTTP 协议 │ ← 传输层协议
└─────────────────────────────────────────┘
3.2 核心组件归属
技术 | 归属 | 用途 | 是否 SSE 专属 |
---|---|---|---|
EventSource |
SSE API | 接收 SSE 消息 | ✅ SSE 专属 |
text/event-stream |
SSE 标准 | 标识 SSE 响应 | ✅ SSE 专属 |
data:\n\n 格式 |
SSE 协议 | SSE 消息格式 | ✅ SSE 专属 |
TextEncoder |
Web Encoding API | 字符串转字节 | ❌ 通用工具 |
ReadableStream |
Streams API | 流式数据处理 | ❌ 通用工具 |
controller |
Streams API | 控制流的发送 | ❌ 通用工具 |
3.3 为什么 SSE 需要这些 API?
🤔 核心问题:HTTP Response 只能返回字节流
// ❌ HTTP 响应不能直接发送 JavaScript 字符串
return new Response('Hello World'); // 这会被自动转换为字节
// ✅ HTTP 响应必须是字节流
return new Response(ReadableStream<Uint8Array>); // 字节流
💡 SSE 的数据流转过程
1. JavaScript 字符串
↓ TextEncoder(字符串 → 字节)
2. Uint8Array 字节数组
↓ ReadableStream(组织成流)
3. ReadableStream<Uint8Array>
↓ HTTP Response
4. 浏览器接收字节流
↓ EventSource(解析 SSE 格式)
5. JavaScript 字符串事件
📝 为什么需要 Web Encoding API?
问题:HTTP 只传输字节,JavaScript 操作字符串
// SSE 格式是字符串
const sseMessage = 'data: Hello World\n\n';
// ❌ 不能直接发送字符串
controller.enqueue(sseMessage); // 类型错误!
// ✅ 必须先转换为字节
const encoder = new TextEncoder();
controller.enqueue(encoder.encode(sseMessage)); // Uint8Array
TextEncoder 的作用:
- 将 JavaScript 字符串编码为 UTF-8 字节数组
- 处理多语言字符(中文、emoji 等)
- 确保网络传输的正确性
const encoder = new TextEncoder();
// 示例:编码不同内容
encoder.encode('Hello'); // Uint8Array [72, 101, 108, 108, 111]
encoder.encode('你好'); // Uint8Array [228, 189, 160, 229, 165, 189]
encoder.encode('🎉'); // Uint8Array [240, 159, 142, 137]
🌊 为什么需要 Web Streams API?
问题 1:SSE 需要持续推送数据
// ❌ 普通响应只能一次性返回
return new Response('data: message\n\n'); // 发送后就结束了
// ✅ ReadableStream 可以持续推送
const readable = new ReadableStream({
async start(controller) {
// 可以多次发送
controller.enqueue(encoder.encode('data: message1\n\n'));
await sleep(1000);
controller.enqueue(encoder.encode('data: message2\n\n'));
await sleep(1000);
controller.enqueue(encoder.encode('data: message3\n\n'));
// 连接保持打开...
},
});
问题 2:SSE 需要控制发送时机
// ReadableStream 的 controller 提供精确控制
const readable = new ReadableStream({
async start(controller) {
// 1. 立即发送初始消息
controller.enqueue(encoder.encode('data: 开始\n\n'));
// 2. 等待异步操作
const result = await fetchData();
// 3. 根据结果发送
for await (const chunk of result) {
controller.enqueue(encoder.encode(`data: ${chunk}\n\n`));
}
// 4. 发送完成事件
controller.enqueue(encoder.encode('event: complete\ndata: 结束\n\n'));
// 5. 可选:关闭流
// controller.close();
},
});
问题 3:SSE 需要处理客户端断开
const readable = new ReadableStream({
async start(controller) {
const interval = setInterval(() => {
controller.enqueue(encoder.encode('data: tick\n\n'));
}, 1000);
// ✅ 关键:客户端断开时清理资源
return () => {
clearInterval(interval);
console.log('客户端断开,已清理定时器');
};
},
cancel(reason) {
// 客户端主动关闭时触发
console.log('客户端取消连接:', reason);
},
});
🔄 完整的 SSE 数据流
export async function GET(request: NextRequest) {
const encoder = new TextEncoder(); // ← 步骤1: 准备编码器
const readable = new ReadableStream({
// ← 步骤2: 创建流
async start(controller) {
// 步骤3: 准备字符串数据
const message = 'Hello World';
const sseFormat = `data: ${message}\n\n`;
// 步骤4: 字符串 → 字节
const bytes = encoder.encode(sseFormat);
// 步骤5: 推送到流中
controller.enqueue(bytes);
// 步骤6: 可以继续推送...
// controller.enqueue(encoder.encode('data: more\n\n'));
},
});
// 步骤7: 包装为 HTTP 响应
return new Response(readable, {
headers: {
'Content-Type': 'text/event-stream', // ← 告诉浏览器这是 SSE
},
});
}
// 客户端接收:
// ReadableStream<Uint8Array> → EventSource 解析 → JavaScript 事件
📊 对比:有无这些 API 的区别
场景 | 没有这些 API | 使用这些 API |
---|---|---|
发送数据 | ❌ 只能一次性返回 | ✅ 可以持续推送 |
字符串处理 | ❌ 需要手动处理编码 | ✅ TextEncoder 自动处理 |
流控制 | ❌ 无法控制发送时机 | ✅ controller 精确控制 |
资源清理 | ❌ 连接断开无通知 | ✅ cancel() 回调清理 |
背压处理 | ❌ 无法知道客户端状态 | ✅ desiredSize 指示队列 |
错误处理 | ❌ 只能返回错误响应 | ✅ 可以在流中发送错误 |
🌊 Web Streams API 完整介绍
4.1 三大核心流类型
ReadableStream(可读流)📖
const readable = new ReadableStream({
// 初始化
start(controller) {
controller.enqueue('数据块1');
controller.enqueue('数据块2');
controller.close();
},
// 消费者准备好接收更多数据时调用
pull(controller) {
// 可以延迟发送数据
},
// 消费者取消订阅时调用
cancel(reason) {
console.log('取消原因:', reason);
},
});
// 消费方式1: Reader
const reader = readable.getReader();
const { value, done } = await reader.read();
// 消费方式2: for await...of(推荐)
for await (const chunk of readable) {
console.log(chunk);
}
应用场景:
- ✅ SSE 服务器推送
- ✅ Fetch API 响应体
- ✅ 文件读取
- ✅ WebSocket 数据接收
WritableStream(可写流)✍️
const writable = new WritableStream({
start(controller) {
console.log('写入流初始化');
},
async write(chunk, controller) {
console.log('接收到:', chunk);
await saveToDatabase(chunk);
},
close() {
console.log('写入完成');
},
abort(reason) {
console.log('写入中止:', reason);
},
});
// 使用方式
const writer = writable.getWriter();
await writer.write('Hello');
await writer.write('World');
await writer.close();
应用场景:
- 文件下载保存
- 数据持久化
- 网络上传
TransformStream(转换流)🔄
// 创建转换流:将文本转为大写
const uppercase = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toUpperCase());
},
});
// 使用管道连接
readable.pipeThrough(uppercase).pipeTo(writable);
// 实际例子:压缩流
const compressor = new TransformStream({
async transform(chunk, controller) {
const compressed = await compress(chunk);
controller.enqueue(compressed);
},
flush(controller) {
// 处理最后的数据
},
});
// 链式处理
fetch('/api/data')
.then((response) => response.body)
.pipeThrough(decompressor)
.pipeThrough(jsonParser)
.pipeTo(display);
应用场景:
- 数据压缩/解压
- 编码转换(Base64、UTF-8)
- 数据加密/解密
- 文本处理(过滤、替换)
4.2 核心控制器
ReadableStreamDefaultController
interface ReadableStreamDefaultController {
enqueue(chunk: any): void; // 添加数据块到队列
close(): void; // 关闭流
error(error: any): void; // 发送错误
readonly desiredSize: number; // 队列中可容纳的数据大小(背压)
}
// 使用示例
const readable = new ReadableStream({
start(controller) {
// 检查背压
if (controller.desiredSize <= 0) {
console.log('队列已满,暂停发送');
return;
}
controller.enqueue('数据');
// 发生错误
if (error) {
controller.error(new Error('处理失败'));
}
// 完成
controller.close();
},
});
WritableStreamDefaultController
interface WritableStreamDefaultController {
error(error: any): void; // 发送错误信号
}
TransformStreamDefaultController
interface TransformStreamDefaultController {
enqueue(chunk: any): void; // 输出转换后的数据
terminate(): void; // 终止流
readonly desiredSize: number; // 背压指示
}
4.3 读取器与写入器
ReadableStreamDefaultReader
const reader = readable.getReader();
// 读取数据
while (true) {
const { value, done } = await reader.read();
if (done) break;
console.log('读取到:', value);
}
// 取消读取
await reader.cancel('用户取消');
// 释放锁(允许其他 reader)
reader.releaseLock();
WritableStreamDefaultWriter
const writer = writable.getWriter();
// 写入数据
await writer.write('数据块1');
await writer.write('数据块2');
// 关闭
await writer.close();
// 中止
await writer.abort('发生错误');
// 释放锁
writer.releaseLock();
4.4 管道操作
// 1. pipeTo - 流向可写流
await readable.pipeTo(writable);
// 2. pipeThrough - 流经转换流
const transformed = readable
.pipeThrough(transformStream1)
.pipeThrough(transformStream2);
// 3. 实际应用:文件处理流水线
fetch('/api/large-file')
.then((response) => response.body) // ReadableStream
.pipeThrough(decompressStream) // 解压
.pipeThrough(decryptStream) // 解密
.pipeThrough(parseStream) // 解析
.pipeThrough(validateStream) // 验证
.pipeTo(saveToFileStream); // 保存
// 4. 带选项的管道
await readable.pipeTo(writable, {
preventClose: true, // 不自动关闭 writable
preventAbort: true, // 不传播 abort
preventCancel: true, // 不传播 cancel
signal: abortSignal, // 取消信号
});
4.5 完整 API 对照表
API | 类型 | 主要方法/属性 | 用途 |
---|---|---|---|
ReadableStream | 可读流 |
getReader() , pipeThrough() , pipeTo()
|
读取数据源 |
WritableStream | 可写流 |
getWriter() , abort()
|
写入数据 |
TransformStream | 转换流 |
readable , writable
|
数据转换 |
ReadableStreamDefaultController | 控制器 |
enqueue() , close() , error()
|
控制可读流 |
WritableStreamDefaultController | 控制器 | error() |
控制可写流 |
TransformStreamDefaultController | 控制器 |
enqueue() , terminate()
|
控制转换流 |
ReadableStreamDefaultReader | 读取器 |
read() , cancel() , releaseLock()
|
读取数据块 |
ReadableStreamBYOBReader | 字节读取器 | read(buffer) |
高效字节读取 |
WritableStreamDefaultWriter | 写入器 |
write() , close() , abort()
|
写入数据块 |
🔤 Web Encoding API 完整介绍
5.1 核心概念
Web Encoding API 提供字符串与字节之间的编码转换功能,是处理网络传输、文件操作的基础工具。
为什么需要编码?
问题:计算机只能处理数字(字节),网络也只能传输字节
解决:需要在 JavaScript 字符串 ↔ 字节数组 之间转换
JavaScript 字符串 "Hello"
↓ TextEncoder
Uint8Array [72, 101, 108, 108, 111] ← 可以通过网络传输
↓ TextDecoder
JavaScript 字符串 "Hello"
5.2 TextEncoder(编码器)
基本用法
// 创建编码器(默认 UTF-8)
const encoder = new TextEncoder();
// 字符串 → 字节数组
const bytes = encoder.encode('Hello World');
console.log(bytes); // Uint8Array(11) [72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]
// 查看编码格式
console.log(encoder.encoding); // "utf-8"(只读,固定为 UTF-8)
API 详解
interface TextEncoder {
readonly encoding: string; // 固定为 "utf-8"
// 编码整个字符串
encode(input?: string): Uint8Array;
// 编码到已有缓冲区(流式编码)
encodeInto(
source: string,
destination: Uint8Array
): TextEncoderEncodeIntoResult;
}
interface TextEncoderEncodeIntoResult {
read: number; // 已读取的源字符数
written: number; // 已写入的字节数
}
encode() 方法
const encoder = new TextEncoder();
// 1. 基本使用
encoder.encode('Hello'); // Uint8Array [72, 101, 108, 108, 111]
// 2. 空字符串
encoder.encode(''); // Uint8Array []
// 3. 中文字符(UTF-8 编码,每个中文 3 字节)
encoder.encode('你好'); // Uint8Array [228, 189, 160, 229, 165, 189]
// 4. Emoji(UTF-8 编码,4 字节)
encoder.encode('🎉'); // Uint8Array [240, 159, 142, 137]
// 5. 混合内容
encoder.encode('Hello 你好 🎉'); // 包含所有字符的字节表示
encodeInto() 方法(流式编码)
const encoder = new TextEncoder();
// 创建目标缓冲区
const buffer = new Uint8Array(20);
// 编码到缓冲区
const result = encoder.encodeInto('Hello World', buffer);
console.log(result);
// { read: 11, written: 11 }
// read: 读取了 11 个字符
// written: 写入了 11 个字节
console.log(buffer);
// Uint8Array [72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0]
// H e l l o 空 W o r l d ← 后面是未使用的 0
// 缓冲区不足的情况
const smallBuffer = new Uint8Array(5);
const result2 = encoder.encodeInto('Hello World', smallBuffer);
console.log(result2);
// { read: 5, written: 5 } ← 只能写入 5 个字节
console.log(smallBuffer);
// Uint8Array [72, 101, 108, 108, 111] ← "Hello" 的字节
实际应用场景
// ✅ SSE 数据编码
const encoder = new TextEncoder();
controller.enqueue(encoder.encode('data: Hello\n\n'));
// ✅ WebSocket 发送文本
const message = encoder.encode(JSON.stringify({ type: 'chat', content: 'Hi' }));
websocket.send(message);
// ✅ 文件写入
const fileContent = encoder.encode('File content here');
await writeFile('output.txt', fileContent);
// ✅ 计算字符串字节长度
const text = 'Hello 你好';
const byteLength = encoder.encode(text).length;
console.log(byteLength); // 14 字节(5 + 1 + 6 + 2)
5.3 TextDecoder(解码器)
基本用法
// 创建解码器(默认 UTF-8)
const decoder = new TextDecoder();
// 字节数组 → 字符串
const bytes = new Uint8Array([72, 101, 108, 108, 111]);
const text = decoder.decode(bytes);
console.log(text); // "Hello"
// 查看解码格式
console.log(decoder.encoding); // "utf-8"
API 详解
interface TextDecoder {
readonly encoding: string; // 编码格式(如 "utf-8")
readonly fatal: boolean; // 是否抛出解码错误
readonly ignoreBOM: boolean; // 是否忽略字节序标记
// 解码字节数组
decode(
input?: BufferSource, // Uint8Array 或 ArrayBuffer
options?: TextDecodeOptions // 解码选项
): string;
}
interface TextDecodeOptions {
stream?: boolean; // 流式解码(保留不完整字符)
}
支持的编码格式
// UTF-8(默认,推荐)
const utf8Decoder = new TextDecoder('utf-8');
// 其他编码格式
const gbkDecoder = new TextDecoder('gbk'); // 中文 GBK
const latinDecoder = new TextDecoder('iso-8859-1'); // 拉丁文
const shiftJisDecoder = new TextDecoder('shift-jis'); // 日文
// 检查支持的编码
try {
new TextDecoder('unknown-encoding');
} catch (error) {
console.error('不支持的编码格式');
}
decode() 方法
const decoder = new TextDecoder();
// 1. 基本解码
const bytes1 = new Uint8Array([72, 101, 108, 108, 111]);
decoder.decode(bytes1); // "Hello"
// 2. 解码中文
const bytes2 = new Uint8Array([228, 189, 160, 229, 165, 189]);
decoder.decode(bytes2); // "你好"
// 3. 解码 Emoji
const bytes3 = new Uint8Array([240, 159, 142, 137]);
decoder.decode(bytes3); // "🎉"
// 4. 空字节
decoder.decode(new Uint8Array([])); // ""
// 5. 使用 ArrayBuffer
const buffer = new ArrayBuffer(5);
const view = new Uint8Array(buffer);
view.set([72, 101, 108, 108, 111]);
decoder.decode(buffer); // "Hello"
流式解码
const decoder = new TextDecoder();
// 问题:多字节字符可能被分割
const part1 = new Uint8Array([228, 189]); // "你" 的前 2 字节
const part2 = new Uint8Array([160]); // "你" 的最后 1 字节
// ❌ 不使用 stream 选项
decoder.decode(part1); // "�" (乱码,字符不完整)
decoder.decode(part2); // "�" (乱码)
// ✅ 使用 stream 选项
const streamDecoder = new TextDecoder();
const text1 = streamDecoder.decode(part1, { stream: true }); // ""(等待更多字节)
const text2 = streamDecoder.decode(part2, { stream: false }); // "你"(完成解码)
console.log(text1 + text2); // "你"
错误处理
// 1. fatal 模式(遇到无效字节抛出错误)
const fatalDecoder = new TextDecoder('utf-8', { fatal: true });
try {
const invalidBytes = new Uint8Array([0xff, 0xfe]); // 无效的 UTF-8
fatalDecoder.decode(invalidBytes);
} catch (error) {
console.error('解码失败:', error); // EncodingError
}
// 2. 非 fatal 模式(替换为 � )
const decoder = new TextDecoder('utf-8', { fatal: false });
const invalidBytes = new Uint8Array([0xff, 0xfe]);
decoder.decode(invalidBytes); // "��"(替换字符)
5.4 完整 API 对照表
API | 方法/属性 | 参数 | 返回值 | 用途 |
---|---|---|---|---|
TextEncoder | constructor() |
- | TextEncoder |
创建 UTF-8 编码器 |
encode(str) |
string |
Uint8Array |
字符串 → 字节数组 | |
encodeInto(str, buffer) |
string, Uint8Array |
{read, written} |
编码到已有缓冲区 | |
encoding |
- | "utf-8" |
编码格式(只读) | |
TextDecoder | constructor(label?, options?) |
string, {fatal?, ignoreBOM?} |
TextDecoder |
创建解码器 |
decode(buffer?, options?) |
BufferSource, {stream?} |
string |
字节数组 → 字符串 | |
encoding |
- | string |
编码格式(只读) | |
fatal |
- | boolean |
是否抛出错误(只读) | |
ignoreBOM |
- | boolean |
是否忽略 BOM(只读) |
5.5 总结
Web Encoding API 核心要点:
- TextEncoder:字符串 → 字节(仅支持 UTF-8)
- TextDecoder:字节 → 字符串(支持多种编码)
-
在 SSE 中的作用:
- 将 SSE 格式字符串编码为字节
- 使 HTTP Response 能够传输文本数据
-
性能优化:
- 复用编码器/解码器实例
- 大量编码时使用
encodeInto()
- 流式解码时使用
{ stream: true }
Sora 2:好模型,但未必是好生意 | 肘子的 Swift 周报 #0105
AI 聊天消息长列表性能优化:后端分页 + 前端虚拟滚动
大家好,今天想和大家分享一个我们在开发 AI 多模态聊天应用时遇到的性能挑战以及解决方案。随着用户使用时间的增加,单个会话的聊天记录可能会积累到成百上千条,这时候性能问题就变得非常突出了。经过一番调研和实践,我们采用了 "后端分页 + 前端虚拟滚动" 的组合方案,效果显著。下面就来详细介绍一下我们的实现思路和遇到的问题。
前言
在开发 AI 聊天应用的过程中,我们发现随着用户使用频率的提高,单个会话的聊天记录会越来越多。最初的实现方式是一次性加载所有历史消息,这就导致了两个明显的问题:一是接口响应缓慢,用户需要等待较长时间才能看到消息;二是前端渲染时出现明显卡顿,影响用户体验。
为了解决这些问题,我们研究了多种长列表优化方案,最终选择了 "后端分页 + 前端虚拟滚动" 的组合策略。经过实践检验,这个方案不仅显著提升了性能,还保证了良好的用户体验。
问题分析
性能瓶颈在哪里?
在决定优化方案之前,我们首先对现有系统进行了全面的性能分析,发现了两个主要瓶颈:
- 网络传输延迟:一次性传输大量聊天记录导致首字节时间(TTFB)过长,用户需要等待 1-2 秒才能看到内容。
- DOM 渲染压力:每条聊天消息都包含用户输入和 AI 回复,实际渲染的 DOM 节点数量是消息数量的两倍。当消息数量达到数千条时,浏览器的重绘和重排开销变得非常巨大,导致滚动不流畅。
优化目标
基于以上分析,我们制定了明确的优化目标:
- 首屏加载时间控制在 500ms 以内
- 滚动流畅度达到 60fps
- 支持无限加载历史记录
- 整个优化过程对用户体验无感知
技术方案
我们采用了经典的 "分段加载" 策略,结合后端和前端的优势,形成了一个完整的解决方案:
┌─────────────────────────────────────┐
│ 后端分页 │
│ - 按时间倒序分页查询 │
│ - 默认返回最新 20 条记录 │
│ - 支持向前翻页加载更早消息 │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ 前端虚拟滚动 │
│ - 只渲染可视区域的消息 │
│ - 动态测量每条消息的真实高度 │
│ - 滚动到顶部自动加载更多 │
└─────────────────────────────────────┘
这个方案的核心思想是 "按需加载" 和 "按需渲染":后端只返回当前需要的数据,前端只渲染用户当前能看到的内容。
后端实现:基于 Prisma 的智能分页
核心代码实现
我们的后端采用 Node.js + Prisma + MySQL 的技术栈,分页实现的关键在于高效的查询和有用的元数据返回:
async getSessionDetail(sessionId, userId, queryParams = {}) {
const { page = 1, limit = 20 } = queryParams
// 参数验证和边界处理
const pageNum = Math.max(1, parseInt(page))
const limitNum = Math.min(100, Math.max(1, parseInt(limit)))
const skip = (pageNum - 1) * limitNum
// 并行查询记录和总数,优化性能
const [chatRecords, totalRecords] = await Promise.all([
prisma.chatRecord.findMany({
where: { session_id: sessionId, user_id: userId },
select: {
id: true,
request_text: true,
response_text: true,
think_text: true,
create_time: true,
// ... 其他字段
},
orderBy: { create_time: 'desc' }, // 最新的在前
skip,
take: limitNum,
}),
prisma.chatRecord.count({
where: { session_id: sessionId, user_id: userId },
}),
])
// 计算分页元信息
const totalPages = Math.ceil(totalRecords / limitNum)
const hasNext = pageNum < totalPages
const hasPrev = pageNum > 1
return {
chat_records: formattedRecords.reverse(), // 翻转顺序,让最早的在前
pagination: {
page: pageNum,
limit: limitNum,
total: totalRecords,
total_pages: totalPages,
hasNext, // 前端据此判断是否还有更多数据
hasPrev,
},
}
}
设计要点
1. 为什么倒序查询再翻转?
我们选择先按 create_time desc
排序查询,这样可以利用索引快速获取最新的 N 条记录。查询完成后再通过 reverse()
方法翻转顺序,确保前端拿到的数据是按时间正序排列的(最早的在前),这样更符合用户查看聊天记录的习惯。
2. 分页元数据的妙用
我们发现返回 hasNext
和 hasPrev
比单纯返回 total_pages
更实用:
- 前端不需要自己计算 "是否还有更多数据"
- 避免了总页数变化带来的边界问题
- 减少了前端的逻辑处理
3. 参数边界处理
为了防止恶意请求或不合理的参数导致系统问题,我们添加了参数边界处理:
const pageNum = Math.max(1, parseInt(page)) // 防止 page < 1
const limitNum = Math.min(100, Math.max(1, parseInt(limit))) // 限制单页最大数量
这是生产环境中非常必要的防护措施。
前端实现:高性能虚拟滚动
虚拟滚动的核心思路
虚拟滚动的本质是 "视口裁剪",也就是说,无论总数据量有多大,我们只渲染当前可视区域内的内容,再加上少量缓冲区内容:
总数据:1000 条消息
可视区:只能看到 5 条
实际渲染:5 + 上下缓冲区 = 15 条
其余的内容用空白占位符来撑开滚动高度,给用户一种所有内容都已加载的错觉。
自研虚拟滚动工具
我们封装了一个 virtualScrollUtil.js
工具,提供了虚拟滚动所需的核心能力:
1. 动态高度测量
聊天消息的高度是不固定的(短消息可能只有几十像素,长消息可能有几百像素),因此需要在运行时动态测量每条消息的实际高度:
const measureVisible = async () => {
await nextTick()
let changed = false
for (const row of visibleItems.value) {
const el = itemRefs.get(row.id)
if (el) {
const h = el.offsetHeight
if (h && itemHeights.get(row.id) !== h) {
itemHeights.set(row.id, h) // 缓存高度
changed = true
}
}
}
// 高度变化时重新计算可见范围
if (changed) computeVisibleRange()
}
2. 缓冲区机制
为了避免快速滚动时出现白屏现象,我们在可视区上下各增加了一定数量的缓冲行:
const estRowsPerViewport = Math.max(1, Math.ceil(viewportH / estimateItemHeight))
const buf = Math.max(1, Math.floor(estRowsPerViewport * bufferMultiplier))
start = Math.max(0, start - buf)
end = Math.min(rows.length, end + buf)
经过多次测试,我们发现将 bufferMultiplier
设为 0.75 效果最佳,即缓冲区约为 0.75 个可视区高度。
3. 顶部插入数据不跳动
当用户向上滚动加载更多历史消息时,如果处理不当会导致页面跳动,影响体验。我们的解决方案是:
// 加载前记住当前滚动高度
const prevScrollHeight = container.scrollHeight
// 插入新数据到顶部
chatMessages.value[sessionId] = [...newMsgs, ...list]
// 加载后恢复滚动位置
await nextTick()
const newScrollHeight = container.scrollHeight
container.scrollTop = newScrollHeight - prevScrollHeight
关键在于计算新增内容的高度差,并将 scrollTop
向下偏移相应的距离,让用户感觉不到内容的变化。
与分页的配合
前端使用 Pinia 来管理每个会话的分页状态:
// 分页状态管理
const paginationState = ref({
[sessionId]: {
currentPage: 1,
hasMore: false,
isLoading: false,
total: 0,
totalPages: 0,
}
})
// 滚动到顶部时自动加载
const handleScroll = () => {
scroller.onScroll() // 更新虚拟滚动范围
const container = messagesContainer.value
const nearTop = container.scrollTop <= 40
if (nearTop && pagination.value?.hasMore && !isLoadingMore.value) {
loadOlder() // 加载更早的消息
}
}
当用户滚动到距离顶部 40px 时,自动触发加载更早的消息。这个阈值经过多次测试,既能保证及时加载,又不会过于频繁地触发请求。
性能优化效果
优化前后的对比数据非常显著:
指标 | 优化前 | 优化后 | 提升 |
---|---|---|---|
首屏加载时间 | 1.8s | 0.3s | 83% ↓ |
首屏渲染 DOM 数量 | 2000+ | 40 | 95% ↓ |
滚动 FPS | 30-40 | 55-60 | 50% ↑ |
内存占用(1000 条消息) | ~180MB | ~45MB | 75% ↓ |
这些改进直接转化为了更好的用户体验,特别是在消息量较大的会话中,用户可以流畅地浏览历史记录。
踩过的坑
在实现这个方案的过程中,我们遇到了不少问题,这里分享几个主要的 "坑":
坑 1:去重问题
由于采用分页加载,当用户快速操作时可能会导致重复加载相同的数据。我们的解决办法是使用 Set 存储已加载的记录 ID:
// 使用 Set 存储已加载的记录 ID
const seenRecordIdsMap = ref({})
const getSeenSet = (sessionId) => {
if (!seenRecordIdsMap.value[sessionId]) {
seenRecordIdsMap.value[sessionId] = new Set()
}
return seenRecordIdsMap.value[sessionId]
}
// 加载时过滤重复数据
data.chat_records.forEach((record) => {
if (seen.has(record.id)) return // 已存在,跳过
seen.add(record.id)
// ... 添加到消息列表
})
坑 2:会话切换时的状态清理
不同会话之间的分页状态是独立的,切换会话时需要注意:
- 不要清空已缓存的消息数据(用户可能会切回来)
- 每个会话的分页状态要独立存储
- 虚拟滚动范围要重新计算
坑 3:新消息到达时的滚动策略
当有新消息到达时,我们希望只有在用户当前浏览到列表底部时才自动滚动到底部,否则会打断用户查看历史消息:
const isNearBottom = () => {
const c = messagesContainer.value
if (!c) return false
const delta = c.scrollHeight - (c.scrollTop + c.clientHeight)
return delta <= 80 // 距底部 80px 内才自动滚动
}
watch(currentMessages, () => {
if (isNearBottom()) {
scrollToBottom()
}
}, { deep: true })
总结
通过 后端分页 + 前端虚拟滚动 的组合方案,我们成功将长列表性能提升了一个数量级。这套方案的核心在于:
- 按需加载:只加载用户需要看的数据
- 按需渲染:只渲染用户能看到的 DOM
- 智能预加载:缓冲区机制保证滚动流畅
- 体验优先:无感知的数据加载和状态切换
如果你的应用也面临长列表性能问题,不妨试试这套方案。我们的代码已经在生产环境稳定运行了一段时间,效果良好。
技术栈:Vue 3 + Pinia + Vite + Node.js + Prisma + MySQL
源码参考:
- 虚拟滚动工具:
virtualScrollUtil.js
- 后端分页服务:
sessionService.js
- 前端集成示例:
ChatView.vue
多模态Ai项目全流程开发中,从需求分析,到Ui设计,前后端开发,部署上线,感兴趣打开链接(带项目功能演示),多模态AI项目开发中...
希望这篇分享对你有所帮助,如果你有更好的想法或遇到了什么问题,欢迎在评论区交流讨论!
百度电商MultiAgent视频生成系统
导读
随着人工智能技术的迅猛发展,AIGC(AI-Generated Content,人工智能生成内容)正逐步重塑内容创作行业的格局。尤其在视频内容领域,传统制作流程周期长、成本高、依赖人工创作,已难以满足日益增长的内容消费需求。AIGC技术的引入,为视频创作带来了前所未有的效率与可能性。AIGC工具在短视频应用率从22 年不足5%跃升到25年35%。电商场景下,越来越多的平台帮助商家进行AIGC商品视频的创作,帮助其提高商品转化率。基于上述两点,电商搜索在今年开始探索AIGC视频自动化生产方案,尝试基于视频自动化混剪,来满足搜索场景下日益增长的内容需求。
01 早期项目演进与问题
项目早期我们整体视频创作中基于大模型完成分镜脚本生成 + 分镜图片素材检索,其他视频元素(脚本脉络/视频标题/布局/音效/特效等)全部通过规则进行选择和生成。但整体后验效果不佳,通过后验分析我们发现了两个很大的问题:
-
规则式方案导致视频整体模板化、同质性严重。
-
整体视频素材大部分由口播加静态图片素材组成,比较死板,吸引力弱。
为解决上述2类问题,我们进行了新一版迭代。主要升级点:
-
规则式视频生成方案升级成 MultiAgent协同视频生成系统,最大化视频先后验指标为目标,动态调度视频元素。
-
素材供给和类型更丰富(视频素材、泛图表),增加视频素材多维度优选能力,大幅提升高清视频素材的时长占比,显著增强视频吸引力。
02 MultiAgent视频生成系统
通过搭建MultiAgent协同视频生成系统,基于视频内容高效满足和视频画面丰富多样为优化目标,模拟人类视频制作流程,完成分镜脚本生成>多类型素材生成->大模型剪辑成片多轮推理。
2.1 分镜脚本生成Agent
分镜脚本生成在项目初期面临两大问题:
问题1:如何提升脚本准确率?
电商场景下品牌/品类/商品信息需要大量准确数据支撑 => 直接根据互联网语料生成准确率不到80%。
解法:脚本生成信息来源以电商高精知识图谱为主,第三方优质视频文案(B站视频等)为辅,保证核心主体信息准确的同时丰富故事细节,脚本可用率大大提升。如下是一个Case:
西门子是什么品牌
提到西门子,很多人第一反应是家里那台带液晶屏的冰箱,或是洗衣机上那个会发光的LOGO。
但你可能不知道,这个看似普通的家电品牌,背后藏着足以改变人类工业史的惊人故事。
1847年,当维尔纳·冯·西门子在柏林一间小作坊里敲打出第一台电报机时,没人能想到这家公司会成为工业界的隐形冠军。
最让人震撼的是西门子的重生能力。
二战期间80%工厂被炸毁,这个德国品牌却用短短五年就杀回巅峰。
就像他们1910年卖给中国石龙坝水电站的发电机——这台百岁高龄的工业活化石,至今还在云南吭哧吭哧运转。
这种近乎变态的耐久度,解释了为什么全球70%的高端燃气轮机市场都被西门子垄断。
但西门子真正的可怕之处在于无处不在。
你手机摄像头里的光学系统,医院CT机的核心部件,甚至造芯片用的UV光刻机,背后都是西门子的技术。
更夸张的是,历史上32位诺贝尔奖得主都依赖西门子显微镜做研究。
这种渗透到科技毛细血管的能力,让它在工业4.0时代依然稳坐神坛。
2024年最新财报暴露了这家老牌巨头的野心:单季度新订单223亿欧元,折合人民币超1700亿元。
更惊人的是研发投入——63亿欧元相当于每天烧掉1.7亿人民币搞创新。
从1872年进入中国交付首台电报机,到如今智能工厂解决方案遍布长三角,西门子用152年时间证明:真正的工业王者,从来都是闷声改变世界。
下次当你打开西门子冰箱取饮料时,不妨多看一眼那个蓝色LOGO。
它不仅是德国制造的品质象征,更是一台持续运转178年的超级印钞机——平均每1.5小时就能创造1个诺贝尔奖级别的技术突破,这样的品牌基因,恐怕连特斯拉都要喊声老师。
问题2:如何提升脚本吸引力?
通用大模型生成脚本冗长拖沓且AI感强 => 无法快速满足用户需求以及脚本吸引力不足。
问题2解法:构建优秀脚本脉络及风格集合,针对不同Query动态选择脚本脉络、风格,提高脚本吸引力。
风格2:历史叙事类风格
开头:
1.通过悬念钩子式开场,把观众带入好奇与期待的情绪,通过颠覆认知的事实陈述,带给观众强烈的入门吸引力。
主体文案
1.通过时间锚点与关键事件叙事,把观众带入到故事中,通过细节化描述,带给观众身临其境的代入感。
2. 通过数据具象化与生活类比,把观众带入真实可感的认知场景,通过技术术语降维解读,带给观众易懂的专业洞察。
3. 通过对比与隐喻强化冲突,把观众带入情感共鸣的高潮,通过辉煌与危机并行的结构,带给观众深度反思的平衡视角。
4.通过转折点戏剧化呈现,把观众带入叙事节奏的起伏中,通过第三方视角引用,带给观众客观可信的品牌背书。
5.通过金句收尾与主题升华,把观众带入余韵悠长的结尾氛围,通过历史寓言或隐喻,带给观众启发性的价值总结。
6. 通过语言节奏控制,把观众带入流畅紧凑的阅读体验,通过文化元素融合,带给观众多元化的审美共鸣。
结尾:添加和用户的互动
效果示例如下:
谁能想到,如今遍布全球的西门子帝国,最初竟诞生在德国柏林一家熟肉店的后院!(开头直接回答用户问题)
1847年,当维尔纳·冯·西门子脱下普鲁士军装,租下那间飘着烤猪肘香味的作坊时,连隔壁面包师傅都嘲笑这个整天摆弄电线的年轻人。
可就是这个被当作怪人的前炮兵军官,用一根电报线撬动了整个工业革命。
故事要从德国北部的小村庄伦特说起。1816年出生的维尔纳从小就有种特殊天赋——他能把拆散的怀表重新组装成会转动的钟,还能用勺子电解出银层给朋友做生日礼物。(通过细节化描述,带给观众身临其境的代入感)
这种对电的痴迷,最终让他在1847年创立了西门子-哈尔斯克公司。
注意,这家公司的第一个爆款产品不是冰箱也不是发电机,而是指针式电报机!
当时的欧洲正处在通信革命前夜。柏林至法兰克福的电报线路项目,让西门子赚到第一桶金。
但真正让公司腾飞的,是维尔纳三兄弟的全球布局战略:大哥坐镇柏林搞研发,二弟威廉攻克英国市场,三弟卡尔甚至把电线铺到了沙皇的冬宫。
到1879年柏林工业博览会上,西门子已经能骄傲地展示世界首辆电力列车——比爱迪生发明电灯还早两年!
如今178年过去,这个德国品牌早已超越国界。2024财年第一季度,西门子新订单额飙升至223亿欧元,在190个国家拥有32万员工。
从你家冰箱里的PT净味技术,到医院的核磁共振设备,甚至太空站的供电系统,那个曾在肉店后院闻着香味饿肚子的发明家,真的让全人类都通上了他的电。
不过最讽刺的是,当年维尔纳为省钱发明的电镀术,如今却成了西门子高端家电的标配工艺。
下次当你打开那台标着SIEMENS的冰箱时,别忘了里面藏着个德国工业史上最美味的创业故事——毕竟没有哪家世界500强,是从闻着烤猪肘香味开始的。
2.2 多类型素材生成
目前AIGC视频中,电商视频素材相比于通用场景素材,存在两点挑战:
- 视频素材少
原始视频少:业界通用视频素材对于电商信息,特别是长尾商品信息覆盖较少。
可用视频少:在电商类视频中,对品牌商品等实体一致性要求极高,进一步加剧视频供给问题。
- 传统视频检索准确率低:电商场景下对于品牌/商品实体一致性要求极高,传统通用视频检索系统在电商场域下实体理解效果差,检索准确率低,导致视频不可用。
针对上述两个挑战,我们提出了两步解决方案:
- 泛图表生成,进一步增加差异化供给: 基于大模型代码生成能力,自动化构建30+个泛图表模板,并通过MCP形式对外开放;通过大模型规划能力,根据脚本选择最优图表模板并生成泛图表内容,端到端图表生成可用率达92%。
图表效果如下:
整体流程如下:
-
素材多维度优选:基于多模态视频理解大模型,从电商实体一致性,视频清晰度等多维度构建端到端优选能力,提升视频素材质量,视频粒度准确率大大提升。
实体一致:基于Qwen2.5-VL-32B模型,对视频中实体细节进行多维度理解推理,尤其注重商品实体一致性。
清晰度高:通过自研模型对视频清晰度划分清晰/普通/模糊三档,对模糊类视频进行过滤。
2.3 大模型剪辑成片
通过大模型多轮规划推理,进行素材/布局/动效/音效等多视频元素全局优选,完成最终视频剪辑并成片。整体流程如下:
03 后续方向演进
- 端到端剧本生成:
现有问题:现有的2.0框架本质上与传统检索系统类似,存在多个子Agent模块前后依赖,这导致了不同链路目标不一致等问题,制约了视频效果的增长。
解决方案:构建剧本生成Agent,基于大模型进行端到端的完整剧本生成。通过端到端的剧本撰写,视频的画面,脚本,BGM可以实现优化目标的统一化。
- AIGC生成式视频:
现有问题:目前视频是基于现有的视频素材打碎重组(混剪)而成的,在很多时候都面临供给不足的问题,而AIGC生成(文生图/视频)的方式能较好的解决这样的问题。
目前困难:AIGC生成目前的可用率仍不足,会出现文字乱码,人物/实体错误,物理规律不遵循等问题,在电商商品场景下尤为明显,这些仍需要进一步去探索和尝试。
HelloGitHub 第 114 期
BaikalDB MCP Server :链接数据库和AI的直通桥
导读
BaikalDB作为服务百度商业产品的分布式存储系统,支撑了整个广告库海量物料的存储。在大语言模型LLM蓬勃发展的现在,想在大模型里使用BaikalDB里的数据进行分析,都需要复杂的定制开发。看BaikalDB如何借助模型上下文协议(MCP),让数据库对话像聊天一样简单——无需编写代码,大语言模型即可完成复杂数据分析。
01 引言
在2025年以前,大语言模型(Large Language Model,LLM)想要使用数据库的数据,都需要开发人员设计接口、开发Agent插件、构建Prompt等费时费力的一系列定制开发;同时面对不同大模型的差异,还需要额外的重复性工作进行适配。
随着模型上下文协议(Model Context Protocol,MCP)的标准化普及,这一局面被彻底重构。MCP通过定义统一的上下文交互规范,为应用程序与AI模型建立了 “通用通信协议”。
基于此,BaikalDB团队创新推出BaikalDB MCP Server,将其打造为连接LLM与分布式存储系统的 “智能USB接口” ——该方案具备三大核心价值:
1. 零开发集成:支持主流LLM通过标准化协议直接访问BaikalDB,无需编写任何适配代码。
2. 全链路自动化:从自然语言意图理解、SQL智能生成到查询执行与数据分析,实现端到端闭环。
3. 多模型兼容性:屏蔽底层技术差异,一套接口适配GPT、Claude、文心一言等各类大模型。
02 MCP: AI USB接口
2024年11月由Anthropic公司提出的模型上下文协议MCP,是一种标准化的大模型与外部数据源、工具交互的开放协议。来源于USB接口范式的设计灵感,MCP的核心思想在于:通过创建一个通用的标准(如USB接口设计),解决大语言模型与外部系统间的“信息孤岛” 问题,该协议通过三大核心原则重构AI开发生态:
1. 即插即用标准化:定义统一的上下文交换格式,使大模型与数据源/工具的对接效率提升80%以上。
2. 组件解耦化:支持不同AI模块的热插拔组合,开发者可像搭积木般构建复杂AI系统。
3. 语义透明化:通过标准化上下文标记,实现跨组件意图传递的零损耗。
△MCP设计理念
2.1 MCP 组成
如上图所示,MCP 由三个核心组件构成:MCP Host、MCP Client 和 MCP Server:
官方文档链接:
modelcontextprotocol.io/clients
modelcontextprotocol.io/quickstart/…
modelcontextprotocol.io/quickstart/…
MCP Server的三类能力:
- 工具类(Tools)—— 模型的「智能外设」
供模型调用的函数或服务,用于执行特定任务。如一个天气查询工具,获取指定城市的天气信息。
- 资源类(Resources)——模型的「知识库」
供模型读取的数据源,如文件数据或 API 响应内容,为模型提供了丰富的上下文信息,增强了模型的理解能力。
- 提示词(Prompts)——模型的「操作指南」
预先编写的模板,旨在帮助用户完成特定的任务,通常是为了优化工具或资源的使用,提供一种更高效、更准确的交互方式。
MCP Client和Server之间的三种通讯方式:
- STDIO 传输
MCP Server运行在本地。
通过标准输入(stdin)和标准输出(stdout)建立双向通信,1对1服务。
- SSE 传输
MCP Server运行在本地或远程运行。
通过服务器发送事件协议(SSE)进行通信,支持N对1服务。
在 2024-11-05 版本废弃,被 Streamable HTTP 替代,后者将 SSE 作为可选的流式传输机制纳入其中。
- Streamable HTTP 传输
MCP Server运行在本地或远程运行。
通过可流式HTTP传输协议通信,支持N对1服务。
支持流式传输,适合大数据量场景,提供更灵活的传输能力
2.2 MCP 流程
文心快码Comate是百度基于文心大模型开发的智能代码助手,旨在通过AI技术重构编程流程,提升开发效率与代码质量。目前Comate不仅支持智能代码生成、单元测试生成等功能,还支持接入外部MCP Server与大模型进行交互。
以在文心快码Comate里通过BaikalDB MCP Server对BaikalDB数据进行查询分析举例:
1. MCP Host:Comate Desktop 作为 Host,负责接收提问【分析42601969用户在 2023-3月每天的转化总数,按照时间升序排序,用折线图展示,并分析趋势走向 】并与大模型交互。大模型解析提问,并生成对应的SQL。
2. MCP Client:当大模型决定需要baikaldb_mcp/read_query Tool,Comate 中内置的 MCP Client 会被激活。这个Client负责与BaikalDB MCP Server建立链接并调用read_query工具。
3. MCP Server:BaikalDB MCP Server被调用,接收、执行查询语句,最终返回SQL执行结果。
完整执行流程:你的问题 → Comate Desktop → 大模型 → 需要查询BaikalDB表,并生成对应SQL → MCP Client 连接 → BaikalDB MCP Server → 执行SQL并返回结果 → Comate生成回答 → 生成折线图。
MCP架构设计使得如Comate等LLM应用,可以在不同场景下灵活调用各种工具和数据源,而开发者只需专注于开发对应的 MCP Server,无需关心 Host 和 Client 的实现细节。
03 BaikalDB MCP Server
3.1 BaikalDB MCP Server主要功能
BaikalDB MCP Server提供了以下功能,支持大模型直接和BaikalDB数据库进行交互:
1. 工具类(Tools):大模型可以根据上下文按需调取的直接和BaikalDB交互的工具。
- 链接操作:链接到指定的BaikalDB库
connect_baikaldb:给定链接信息(包括host,port,username,password,database),连接到对应的BaikalDB数据库,使用过程中支持动态切换不同的BaikalDB集群。
- 查询操作:包括获取库表信息,执行SELECT/DML SQL,分析SQL索引使用扫描量等。
show_all_databases:获取所有的数据库列表信息。
db_overview:获取指定数据库中所有表的概览信息。
table_overview:获取指定表的概览信息,包括:表结构(show create table)、表示例数据(select * from table limit 3)。
read_query:执行select sql并返回csv结果数据,大模型拿到结果可以进行智能分析、智能绘图等等。
write_query:执行建删表、插入删除变更等dml sql并返回操作结果。
analyze_select_query:分析查询SQL执行情况:使用的索引,索引扫描/过滤/反查行数等,支持大模型进行索引分析推荐。
- 模板操作(优化复杂场景使用):支持预先导入模板SQL(如百度智能云推出的Sugar BI SQL模板),帮助大模型理解业务逻辑,后续大模型可在模板SQL基础上改写查询分析,并支持基于模板进行二次查询(如再次聚合),不同BaikalDB用户之间模板不共享。
get_all_bi_sql_template_list:查询当前BaikalDB用户已导入的SQL模板列表。
get_bi_sql_template_detail:获取SQL模板详细信息,包括SQL模板,相关表Schema等。
add_bi_sql_template:指定模板说明,模板SQL等,添加新的SQL模板。
delete_bi_sql_template:删除指定的SQL模板。
2. 资源类 (Resources) 和 提示词 (Prompts):
- 目前BaikalDB MCP Server暂未定义资源和提示词,未来会根据使用场景灵活添加,以更好的引导大模型和BaikalDB进行交互。
通过以上工具,BaikalDB MCP Server使得大模型能自主的查询/操作数据库,进行多轮数据智能分析,并且可以结合大模型和其他MCP能力,并高效的通过多种形式呈现分析结果(如Excel文本,绘制图表等)。
3.2 BaikalDB MCP Server应用场景
BaikalDB MCP Server拥有以上能力后,就可以在以下场景中进行使用:
1. 实时数据分析和智能报表
- 大模型可以实时查询BaikalDB的业务数据,生成可视化报表,并可结合历史上下文生成分析报告或者建议。
2. 多数据源联邦查询分析
- 通过MCP支持大模型同时访问BaikalDB和其他数据源(如知识库、Mysql等),实现联邦分析。
3. 开发测试提效
- 在开发测试过程中,通过自然语言交互,建删改表、增删改查、构造测试数据、分析SQL执行情况等,不用额外切多个窗口执行SQL操作。
04 BaikalDB MCP Server使用
BaikalDB MCP Server使得BaikalDB不单是个高性能的分布式数据库,还是大模型的分析执行插件,使得用户不再需要任何开发,即可对BaikalDB存储的数据进行智能分析。
4.1 Comate 配置
以Comate举例:按照以下图示步骤,将BaikalDB MCP Server json配置添加到Comate MCP配置文件中,即可以在Comate中使用大模型操作BaikalDB数据库。当然后续我们会尝试将BaikalDB MCP Server发布到MCP仓库,使得配置更简单!
BaikalDB MCP Server Json配置如下:
{
"mcpServers": {
"baikaldb_mcp": {
"transport": "sse/streamableHttp",
"url": "BaikalDB MCP Server URL",
"headers": {},
"timeout": 50
}
}
}
4.2 Demo 演示
示例1:智能分析
下方视频展示了,在Comate中用自然语言对数据库数据进行智能分析和图表展示。 mpvideo.qpic.cn/0bc34mc4gaa…
示例2:开发测试提效
下方视频展示了,开发测试过程中的智能建表、导数、SQL执行分析、索引推荐等。 mpvideo.qpic.cn/0b2eiaadqaa…
示例3:基于模板智能分析
下方视频展示了,在复杂业务场景中,通过预先导入的BI SQL模板进行更高效准确的智能分析。 mpvideo.qpic.cn/0bc3omc24aa…
05 总结
BaikalDB MCP Server的核心价值在于打破了数据库数据的信息壁垒,构建了一条完整的智能数据处理链路,实现了从自然语言解析到业务建议输出的端到端能力:
-
自然语言理解:将非结构化查询转化为结构化意图。
-
数据库操作:自动生成并执行SQL语句。
-
数据分析:对查询结果进行多维解读并生成可执行建议。
但是也存在一些问题:
-
SQL生成准确性高度依赖元数据质量(如表结构、字段注释)。
-
复杂业务逻辑描述困难。
-
大模型在长上下文中的注意力分配问题。
当然,随着大模型推理能力的持续提升和MCP协议生态的完善,这种数据智能范式将在金融风控、供应链优化、智能客服等复杂业务场景中展现出更大的价值潜力。
一文解码百度地图ETA
你有没有这样的体验?导航说30分钟能到,结果真的一分不差?
有时候导航告诉你要绕行5分钟的路,其实省下了20分钟的堵车。
这些神奇的“预知能力”,就是我们常听到的 ETA(Estimated Time of Arrival,预计到达时间),别看它们只是一个个数字,其实背后藏着一整套复杂又高效的技术体系。
百度地图 ETA
到底是怎么精准计算出来的呢?
【AI地图 Tech 说】第二期将为你揭开奥秘!
01 基础介绍
ETA 预测的本质,就是给定出发地、目的地和出发时间后,预测驾车所需的时间。例如,当你在某个时间 T 请求路线(如 Route = a→b→c→d→e)时,ETA 系统便开始计算驾车预计行驶的时长。
百度地图 ETA(未来出行)是地图导航的基础功能,其技术演进共经历了四个发展阶段。
▎ 1.0时代:静态 ETA(2010年前)
最初,百度地图 ETA 功能的计算方式极为简单,仅通过距离除以限速得出。然而,这种方式计算出的结果误差常常超过30%,一旦遭遇交通拥堵状况,更是完全无法应对,由此引发了用户的诸多吐槽。
▎ 2.0时代:动态 ETA(2010-2015年)
百度地图首次接入实时交通数据,能够识别实时拥堵路段并提供基本绕行建议。然而,这种方法仍无法预测拥堵的进一步变化趋势。
▎ 3.0时代:个性化 ETA(2015-2021年)
通过引入机器学习与用户画像,百度地图开始分析驾驶习惯(如激进型或保守型司机)、车辆类型(如货车或新能源车),实现了针对不同人群的个性化路线推荐。
▎ 4.0时代:预见性 ETA(2021年至今)
百度地图融入 AI 技术,如预训练大模型和时空预测技术,开始实现未来30-60分钟的精准路况预测,甚至能准确量化天气对行车速度的影响。
02 技术优势
百度地图 ETA 为何如此精准?背后的核心在于预训练交通大模型与端到端路线通行时间预测两大技术。
▎ 预训练交通大模型:海量 AI 知识集成体
预训练交通大模型通过地图脱敏轨迹数据,建模城市交通规律,为智能交通提供底座能力。预训练交通大模型基于千亿公里驾驶数据,能够精准捕捉不同城市在时段、天气、区域上的交通规律,如北京周一比周五早高峰堵12%、上海雨天车速下降22%、深圳科技园晚高峰比早高峰堵35%。同时,该模型还具备持续学习优化能力,每天都会结合最新观察到的真实拥堵情况自动更新模型参数。
预训练交通大模型整体框架
预训练交通大模型的框架主要分为3个部分:
交通大模型以及下游应用
■ Large-Scale Traffic Corpus(大型交通语料数据)
将原始的脱敏 GPS 轨迹点处理成路段粒度的交通时序信息和路线粒度的个性化导航行为。
■ Pre-Train Model(预训练模型)
基于历史交通大数据充分训练预训练模型,表征普适性的交通规律信息。
■ Downstream Task(下游任务)
基于预训练的交通图嵌入,通过 Zero-Shot 或者 Fine-tune 应用于通行时间预估、交通流量预估、路线排序、智能信控等场景。
▎ 端到端路线通行时间预测:基于交通大模型 FineTune 的 ETA-GNN AI 仿真推演路线模型
在预训练交通大模型基础上,百度地图进一步应用端到端路线通行时间预测,进行更细致的 AI 仿真推演,不再局限于逐路段的简单计算,而是精确模拟红绿灯等待时间、前方车辆汇入情况及施工路段的实际通行效率。同时通过动态概率模型实时评估,决策绕行还是等待,以达到最佳出行策略,预测准确率高达92%。
SFT-ETA 路线模型
ETA 路线模型预测 Pipeline
端到端路线预测体系涵盖以下核心能力:
■ 长时流量预测能力(Supervised FineTune)
全天候预测能力:通过对历史流量数据的监督微调,模型可实现对未来 24小时路段流量变化趋势的精准预测,适用于节假日、景区周边等高动态场景。
零样本迁移泛化:预训练模型内置“早晚高峰模式库”,可直接迁移至新城市路网,实现冷启动场景下的预测精度显著提升。
■ 动态交通关系图谱建模
时空图表示学习:捕捉交通流随时间与空间变化的普适规律。
路网级传播效应建模:通过图神经网络(GNN)结构,量化不同路段之间的流量传导影响,实现更高精度的区域级拥堵预测与调度模拟。
■ 地理语义位置编码(GeoEmbedding)
多维地理语义融合:将传统经纬度转换为包含道路等级、POI 密度、地形坡度等语义信息的向量表示。
跨模态建模能力:融合天气、热度等环境信息,实现对不同条件下相同路段的动态编码与差异化建模,例如“暴雨下立交桥”和“晴天立交桥”的通行效率差异。
■ 轨迹表示学习与个性化 ETA
行为建模:通过车辆历史脱敏的轨迹聚类,区分不同驾驶风格(如保守型 vs 效率型),提供分群精准 ETA 预测。
实时风格感知与动态修正:感知车辆当前驾驶状态(如频繁变道、急加速等),动态调整 ETA 和路径建议,实现个性化自适应路线仿真与推荐。
03 应用场景
百度地图 ETA 广泛应用于各类场景中:
日常通勤:准确预测早晚高峰路况,帮助通勤族合理安排出行。
机场接送:精准判断当前出发是否能赶上航班,解决旅途焦虑。
重大活动预警:如演唱会结束前提前提醒车主提前离场,避免拥堵。
节假日旅游:提前预测旅游景区附近的拥堵趋势,提供更舒适的出游体验。
通过持续的技术进化和 AI 驱动的全面赋能,百度地图的 ETA 精准度在短途、长途、拥堵、节假日等多个场景均已显著领先行业水平,在用户感知层面更显稳健和准确。更值得一提的是,在节假日(尤其“五一”这类与日常规律差异显著的场景下),其表现尤为突出。
出行从此告别盲目与焦虑
百度地图将每一次的未知变成清晰的规划
让用户安心出发,自信抵达!
一文解码百度地图AI导航“小度想想”
你有没有过这样的体验?在高速上对着导航喊“小度小度”,它就神奇地回应道“来了”;在地下车库问“最近的充电桩”,屏幕立刻跳出相关的充电桩指引;甚至对车载语音助手说“有点冷”,空调的温度就会悄悄调高。这些看似“读心术”的交互背后,藏着一个能听懂人话、能感知环境、能精准应答的“数字领航员”。
当你说“查找故宫附近的粤菜馆”时,系统不仅要从3亿多条 POI 数据中精准定位,还要理解“附近”是500米还是3公里;当你追问“有包厢吗”,它甚至能调用餐厅实时预订系统。这些看似简单的对话,需要跨越语音识别、语义理解、内容获取、答案生成等多重技术关卡。
百度地图 AI 导航小度想想
如何将自然语言转化为精准指令?
那些“秒回”的答案又是怎样炼成的?
【AI 地图 Tech 说】第三期将带你拆解这座“数字领航员”的魔法工厂,看看从“听清”到“听懂”方面,究竟藏着多少黑科技。
上图说明了从用户请求到最终执行的整个过程,可以看到其中经过了语音识别、意图解析、技能承接等主要的环节!
01 语音指令的解码之旅:从声波到文本
当用户说出"导航到故宫博物院"时,系统首先启动声学模型将声波转化为文字。这个看似简单的步骤,其实也不容易,蕴含三层技术环节:
▎ 基础识别
其实就是我们大家常说的语音识别技术,它利用深度学习模型将声波信号转化为二进制序列,结合声学模型与发音词典生成初步文本。语音识别技术近年来经历了白盒化到黑盒化的演进,其性能、效果都有很大的提升,大家应该都已经比较熟悉。但相对于安静室内环境,用户在户外使用小度想想的时候,还有一类常见的问题是拒识。根据统计,至少有15%左右的语音请求是由于误唤醒/误收音引入的(非用户主观需求)。小度想想,需要考虑到行驶过程中的风噪、聊天、多媒体播放等复杂噪音场景,百度地图引入了双重拒识判断模型(声学拒识、语义拒识),提前对问题请求进行甄别和提前拦截,最大限度降低用户干扰,大幅提升用户体验。
▎ 纠错
通过语言模型(如BERT、N-Gram)对识别结果进行上下文纠错,例如将“北经”修正为“北京”。这是小度想想相对于通用的语音助手的优势所在,在纠错的过程中,会使用包括地图 POI 数据、路名数据等专业字典进行参考。百度地图建设了超亿条 POI 数据的本名、别名、关联名的地理知识图谱,将 POI 的各种表达方式建立标准化映射。在此过程中,还需要构建错误拼音-标准名称的双向索引表,支持"西单大悦成"→"西单大悦城"这样的智能纠错。
▎ 排序
在实际工程中,纠错手段不可能只有一个,因此就需要在上述流程完成后,基于多个逻辑,会输出多个可能的识别结果。这里就会基于用户之前的对话习惯,以及一些其他基于先验知识和统计学习的置信度评分算法,从多个候选文本中选取最优结果(比如“横屏模式”,在排序中会优于“红屏模式”)。
02 意图解析的"翻译官":把自然语言转化为机器指令
当从语音的音频识别为自然语言之后,下一步就是将其转化为机器指令。这里包括几个关键技术:
技术亮点一:『意图模板匹配』
基于自然语言处理(NLP)技术,完成实体识别(如时间“明天”、地点“北京”)、意图分类(如“天气查询”)、情感分析(如用户是否急躁)。过去的语义理解,更多使用模板类技术,如下图所示,针对用户问询的内容抽取出关键要素后,再看匹配了哪种需求表达方式,这称之为一个“意图模板”,基于大量预置的模板就可以实现大部分指令的识别。
技术亮点二: 『生成式意图理解』
模板化语义理解能解决很多问题,但是存在的关键短板在于泛化理解能力不足,同时高度依赖领域知识积累,需要提前做大量的模板标注,还要解决相近表达方式的模板冲突问题,当模板数量达到一定程度后维护成本就会增加。LLM 的出现,另辟蹊径地解决了这个问题。其核心优点是端到端利用 LLM 的上下文理解能力,直接解析用户自然语言中的隐含需求,形成对“口语表达中蕴含的本质意图”的理解,这个过程中无需构造模板,而是提前将全量承接 API 的参数规范作为“知识”以Prompt的方式注入 LLM,使其自主选择 API 并填充参数。举例来说,我们可以给大模型这样的 Prompt:
角色:你是一个语音助手语义解析器,目标是将用户指令转换为API调用
参考资料:可用的API及参数如下:
{API参数规范库}
用户指令:{user_query}
任务:请按以下步骤执行:
1. 选择最匹配的API;
2. 从指令中提取参数值,若未明确提及则设为null;
3. 输出JSON格式,包含api_name和parameters。
预期输出:{"api_name":
"search_flight", "parameters": {"departure_city":
"北京", ...}}
大模型就能输出针对 user_query 最合适的工具调用参数,跳过了映射的环节,减少了折损,同时因为 LLM 对世界的强刻画能力,使泛化能力也大幅增强,这种模式已经在业内广泛使用,成为提升语义理解能力的主流方法。当然,大模型的应用中,少不了有成本、响应时间上的难题,所以实际工程中还是会大小模型混用,或者用小模型做定向的精调,来实现成本、性能和效果的兼顾。
技术亮点三:『工具调用』
工具调用是小度想想的下半身,是能够准确承接用户需求的关键支撑。其本质上可以理解为一系列 API 接口的调用。当调用序列复杂了之后,调用状态的维护就会成为问题,小度想想针对多轮复杂工具调用,提出了基于技能的状态机架构,任意复杂的操作,都可以基于这套架构来统一表达。
技术亮点四:『生成式 AI 时代的工具调用进阶』
在大模型的时代,为了提升工程化的效果,在 API 接口的基础上又诞生了两个公认的技术范式:
- MCP:聚焦模型与外部工具的连接,提供统一接口(如数据库、API调用),类似“AI 的 USB 接口”,降低跨模型开发成本。只要所有工具都以 MCP 的协议接入,那么大模型就可以知道这个工具能力的存在,从而能做到在合适的时候调用它。
欢迎使用百度地图 MCP 服务
- RAG:RAG 本质上是对问答能力的数据增强,如果小度想想仅仅基于老旧的 LLM 底座来回答问题,会有很严重的幻觉发生。为了解决这个问题,往往使用检索增强生成(Retrieval Augmented Generation,简称 RAG),百度地图将所有的地图领域数据以结构化来存储,然后在用户提问后,以向量相似性找到对应参考数据,并取出再用 LLM 做汇总,就相当于从“闭卷考试”变成了“开卷考试”,从而保证了答案的精准性。
03 持续提升生产力:从语音助手到智能体
随着 LLM 的能力越来强,我们发现,它的强大理解能力,对于一个一般化的常识问题,能给出相当接近人类的回答。那么是否它能模拟很多团队协作的真人,甚至以硅基生命来承接现实世界的生产力?这就是智能体(Agent)要考虑的问题了。智能体是这两年 AI 领域最火的词之一,它是基于人工智能技术在某个领域体现高度智能,显著提升人类工作效率的信息系统,相对于“语音助手”,更偏重于“通过观察、思考、权衡利弊,动态自主调用基础能力、高准确地解决复杂业务问题”的特性。
以自动驾驶场景为例,智能体可以实时感知车辆周围的路况、其他车辆的行驶状态、交通信号灯的变化等关键信息,为后续决策提供坚实的数据基础。自主决策能力堪称智能体的 “大脑”,它依据感知到的环境信息,结合内部预设的规则和先进算法,迅速、准确地做出决策。在面对复杂路况时,自动驾驶智能体能够综合分析各种因素,精准判断是加速、减速还是转弯,以确保车辆行驶的安全与高效。又如在智能物流配送中,智能体的核心目标是按时将货物准确送达目的地,为此它会综合考量实时路况、车辆载重等信息,动态规划最优配送路线,克服重重困难以达成目标。
回到语音助手这个场景,结合地图智能体的任务,首先要针对地图场景深入精调大模型,百度地图通过文心一言基座大模型进行二次预训练、SFT、强化学习等手段,使地图大模型能够精确理解用户在地图中的各种常见表达,理解准确率高达95%以上。
此外,针对复杂任务的执行,还要引入的两个特性是记忆和反思:
-
记忆能力:当用户表达不完整的需求时,能够基于之前的问答和用户行为,自动补全对话内容(如用户问“今天限行吗?”默认补充用户所在城市),因此需要构建记忆能力,用于存储历史交互数据、用户偏好与领域知识(如常用地址、路线选择习惯、节假日出行规律),为意图理解与决策提供背景支持,减少重复询问并提升个性化水平。这里面的短期记忆一般是指从启动会话至今的内容,往往持续数分钟,而长期记忆则是用户相对稳定固化的特征,就地图智能体来说,用户的搜索、导航记录等都是长期记忆的范畴。
-
反思能力:一个初始状态的智能体,在应对用户复杂需求以及实时环境快速变化时,往往会出现理解偏差、输出内容不完备与知识更新滞后等问题。引入反思(Reflection)能力,能显著提升服务的精准性与智能化水平。基于上述记忆-反思流程图,可以看到反思能力能不断地自我判断当前的答案是否满意。当然,客观来说,在大部分领域很难实现完美的反思能力,因为反思的本质是要在将答案呈现给人之前就能判断其质量,这里面存在大量主观因素和模棱两可的问题,在这个过程中,LLM 是第一大功臣,可以说针对语音对话类场景,没有 LLM 纯靠规则就不可能实现普遍有效的反思。除此之外,长短期记忆也起到了重要的作用,它能够结合用户之前的习惯,猜测当前的结果是否符合用户预期,如果不符合,会主动打回进行重新理解和执行。
04 案例解析:天气查询的完整技术链路
以一个简单的“明天北京会下雨吗”为例,系统执行以下操作:
-
语音识别:ASR 引擎输出“明天北京会下雨吗”文本;
-
语义理解:通过注册到小度想想的工具,结合这段文本,输出应当调用天气 API,获取相关数据;
-
服务调用:调用天气 API 获取预测天气数据;
-
答案生成:输出“明天北京阴有雨,15-25℃”;
-
反思与重新生成: LLM 审视这个答案,认为还不够详细,反思后认为应该按时间段细化降水概率,因此重新请求天气 API,获取更详细的降雨预测数据,并呈现给用户。
随着多模态大模型以及自动驾驶技术的发展,未来的小度想想会有更多的可能性。从大的趋势来说,语音语义一体化大模型正在逐渐成熟,2025年3月31日,百度在 AI DAY 上发布了业界首个基于全新互相关注意力(Cross - Attention)的端到端语音语言大模型,该模型实现了超低时延与超低成本。另外,多模态的对话(比如视频 AI 对话)和自动驾驶的结合也值得重视,比如通过车载摄像头识别"前方学校区域"并自动减速;通过声纹、视频和车辆传感器识别人、车的异常,主动采取应对措施;而在导航行中播报的时候,所有内容都是基于实时动态数据进行人格化生成,再也不像机器人那样的生硬,而是像真人一样地交流,让我们的出行更舒适高效。