项目定位:Microsoft 官方发布的生产级 Solution Accelerator,展示如何在 Azure 上构建一个支持语音/电话/文字的多域 AI 客服系统,集成多 Agent 编排、实时流式推理、RAG 知识检索与完整可观测性。
整体代码库
引言
plain
## 输入 / 输出梳理
- 输入:用户的实时语音流(PCM16 音频帧,base64编码),通过 WebSocket 推送
- 输出:AI Agent 的实时语音回复(PCM16 音频帧流)+ 文字转录 + 工具调用结果(订票/改签等实际业务操作)
## 核心难点分析
### 难点1:多 Agent 无缝切换
语音对话中,用户在同一通话里可能从"酒店预订"跳到"机票查询"。
传统 chatbot 做法是让 LLM 自己判断该叫哪个 agent,但这会把这个判断负担加到主模型上,
延迟高、容易出错。
作者的 Engineering Trick:
用一个独立的 "分类专用小模型"(GPT-4o-mini 或 fine-tuned 分类器)来做 detect_intent(),
将"意图路由"和"对话生成"完全解耦。小模型 max_tokens=20,极其轻量,不影响主模型 latency。
意图切换后通过 update_session() 热替换 kernel+instructions,不断连接、不重建 WebSocket。
### 难点2:实时语音流与 Agent 工具调用的并发协调
音频流是持续推送的,但 Agent 可能正在执行 DB 查询或向量搜索。
如果不加锁,agent 回复音频帧和工具调用结果可能乱序。
作者的 Trick:
active_response flag + asyncio.gather 双协程模式。
收到 input_audio_transcription.completed 后才触发 response.create,
保证"先完成语音识别→再做意图检测→再生成回复"的严格时序。
### 难点3:水平扩展下的会话状态持久化
多副本部署时,同一 session_key 可能落到不同 Pod。
作者用 SessionState 类做了优雅的 双模式(in-memory/Redis)无缝切换,
Chat History 以 pickle+base64 的方式存 Redis,反序列化后直接喂给 ChatHistoryTruncationReducer。
### 难点4:知识库防幻觉
Persona YAML 里明确约束:"Do not generate answers that are not based on the search information."
工具层做了向量语义搜索,LLM 只能 cite 工具返回的内容,形成自然的 RAG 防幻觉闭环。
### 合理推测(原文未完全揭示的部分)
- hotel_policy.json 文件 13MB+,内部应是 pre-computed embedding 的 JSON 数组
(每条 { id, policy_text, policy_text_embedding })
- fine-tuned intent 分类模型部署在 Azure ML Online Endpoint(有专用的 INTENT_SHIFT_API_URL/KEY/DEPLOYMENT 三元素)
- 训练数据生成采用 LLM-as-data-generator(用 GPT-4 生成 3253 条标注样本),这是典型的 synthetic data 方案LLM
项目全局视角
业务痛点
传统客服系统面临三大天花板:
| 痛点 | 传统方案的局限 | 本方案如何突破 |
|---|---|---|
| 跨域问题跳转 | 客户从酒店问题问到机票问题,需要人工转接 | 实时意图检测,毫秒级无感切换 AI Agent |
| 幻觉与合规风险 | LLM 可能编造政策内容 | 强制 RAG:Agent 只能引用工具返回内容 |
| 实时语音延迟 | TTS+ASR 两跳带来 >1s 延迟 | Azure OpenAI GPT-4o Realtime API,端到端 <200ms |
| 水平扩展 | Session 状态绑定单机 | Redis 分布式 Session Store,支持无状态横扩 |
"胜负手"三件套
- GPT-4o Realtime WebSocket:语音直接进模型**,省去 ASR→文本→TTS 三跳,**是整个方案低延迟的根基。
- 独立意图分类器:将"路由判断"从主模型解耦,是多 Agent 无缝切换的核心 Engineering Trick。
- YAML 驱动的 Agent 人格模板 :用
{customer_name}/{customer_id}占位符在运行时注入上下文,实现**"零改代码新增 Agent"**的可扩展性。
多模态数据摄入与解析 (Data Ingestion & Parsing)
语音流的"解析":PCM16 实时帧
这个方案中的"多模态"核心是语音模态。数据摄入链路如下:
What(是什么) :用户麦克风采集的原始音频,以 PCM16 格式(16-bit 有符号整数,单声道)逐帧 base64 编码 后通过 WebSocket 推送。
Why(为什么):PCM16 是最原始的无损格式,无需解码开销;base64 编码让二进制数据在 JSON 文本帧中安全传输;GPT-4o Realtime API 原生支持此格式。
How(怎么实现):
python
# frontend/src/hooks/useRealtime.tsx --- 浏览器端音频采集与发送
const addUserAudio = (base64Audio: string) => {
const command: InputAudioBufferAppendCommand = {
type: "input_audio_buffer.append",
audio: base64Audio // PCM16 帧, base64 编码
};
sendJsonMessage(command);
};
python
# rtmt.py --- 后端接收并转发给 Azure OpenAI Realtime
elif msg_type == SendEvents.INPUT_AUDIO_BUFFER_APPEND:
audio_data = message.get("audio")
if audio_data:
await realtime_client.send(
event=RealtimeAudioEvent(
audio=AudioContent(
data=audio_data, data_format="base64"),
)
)
关键参数配置 :Turn Detection 使用
**server_vad**(服务端语音活动检测),阈值 0.5,前置填充 300ms,静音截断 200ms。这意味着用户说完话 200ms 后自动触发推理,极大降低感知延迟。
电话信道的特殊接入:ACS Bridge
What :Azure Communication Services(ACS)的电话信道传来的是 PCM24K_MONO(24kHz 单声道)格式的混合音频流。
Why :电话网络的 PSTN 信号与 WebSocket 完全异构,需要一个专用的 Bridge 层来适配协议差异。
How(ACS 桥接器 acs_realtime.py 的核心逻辑):
python
# 1. ACS 通过 EventGrid 回调通知有来电
# 2. Bridge 应答并建立媒体流 WebSocket(以 callerId 为 session key)
# 3. 双向桥接:ACS音频 → realtime 服务 / realtime回复 → ACS
async def forward_acs_to_realtime():
# ACS 消息格式:{"kind": "AudioData", "audioData": {"data": "<base64>"}}
# 转换成 realtime 格式:{"type": "input_audio_buffer.append", "audio": "<base64>"}
...
async def forward_realtime_to_acs():
# 关键:检测到 "input_audio_buffer.speech_started" 时,
# 向 ACS 发送 StopAudio 信号实现"打断"(barge-in)
if message.get("type") == "input_audio_buffer.speech_started":
await websocket.send(json.dumps(
{"Kind": "StopAudio", "AudioData": None, "StopAudio": {}}
))
工程亮点 :Barge-in(用户打断 AI 说话 )的实现非常精妙------当检测到用户开始说话(
speech_started事件),立即向 ACS 发StopAudio指令,停止正在播放的 AI 音频,实现真正自然的打断体验。
知识库的预计算嵌入(RAG 数据摄入)
What :酒店/航班政策知识库以 hotel_policy.json / flight_policy.json 存储,内部是预计算好的 embedding 数组:
json
[
{
"id": "hotel_policy_001",
"policy_text": "Check-in time is 3:00 PM...",
"policy_text_embedding": [0.0123, -0.0456, ...] // 1536-dim vector
},
...
]
Why:在线 query 时只需要对 query 做一次 embedding,然后与预计算的向量做余弦相似度计算,不需要实时 embed 所有文档,延迟极低。
How(嵌入搜索实现):
python
# hotel_plugins.py --- 极简但高效的向量检索实现
class SearchClient:
def __init__(self, emb_map_file_path: str):
with open(emb_map_file_path) as file:
self.chunks_emb = json.load(file) # 一次性加载全部预计算向量到内存
def find_article(self, question: str, topk: int = 3) -> str:
input_vector = get_embedding(question) # 仅对 query 做一次在线 embedding
cosine_list = [
(item['id'], item['policy_text'],
1 - spatial.distance.cosine(input_vector, item['policy_text_embedding']))
for item in self.chunks_emb
]
cosine_list.sort(key=lambda x: x[2], reverse=True)
return "\n".join(f"{chunk_id}\n{content}"
for chunk_id, content, _ in cosine_list[:topk])
切块策略(Chunking) :从文件大小(~13MB)和结构推断,每个
policy_text对应一条政策条款(段落级),粒度适中------过细会损失上下文,过粗会引入噪声。这是典型的**固定语义段落切分**策略。
检索与召回引擎 (Retrieval & Reranking)
检索方案:纯向量语义检索
What :使用 Azure OpenAI Embedding 模型(text-embedding-ada-002 或 text-embedding-3-small)将 query 映射为向量,用 scipy.spatial.distance.cosine 计算余弦距离,top-3 召回。
Why:
- 政策问答的 query 通常是自然语言("宠物政策是什么"),语义检索比关键词检索更鲁棒。
- 知识库规模小(政策条款有限),暴力全量余弦计算(O(n))完全够用,无需 ANN 索引。
- 不引入 BM25 的原因:政策文本通常没有严格的关键词依赖,纯语义检索足够精准。
How :如上 SearchClient.find_article() 实现。
生产扩展建议 (文档中明确提及):当知识库规模增长,迁移到 Azure AI Search / Pinecone / Qdrant,无需改动上层 Agent 调用逻辑,因为知识检索被完全封装在
@kernel_function工具中。
Reranker 的缺失与其合理性
本方案未使用独立的 Reranker,理由充分:
- Top-3 召回的文档会全部拼接送入 GPT-4o,GPT-4o 本身在处理 3 个候选段落时具备足够的"内置 rerank"能力(注意力机制会自然聚焦最相关内容)。
- 政策 QA 场景的召回精度本身较高****,引入独立 Reranker 收益边际较小,反而增加延迟。
双路数据访问:向量 + SQL
这是本方案检索层最值得关注的设计------两种完全不同的检索范式共存于同一 Agent:
| 检索类型 | 用途 | 工具函数 | 返回方式 |
|---|---|---|---|
| 向量语义检索 | 政策/FAQ 问答 | search_hotel_knowledgebase |
Top-3 文本片段 |
| 精确SQL查询 | 订单/预订/航班状态 | load_user_reservation_info |
结构化 JSON |
两者的协同 :Agent 接到政策问题 → 调 search_*_knowledgebase;接到订单问题 → 调 SQL 工具 。GPT-4o 的 Function Calling 能力负责判断"什么问题用什么工具",这本质上是一个轻量级的 Intent-to-Tool 路由。
Agent 编排与防幻觉
三层 Agent 架构
plain
┌─────────────────────────────────────────────────┐
│ Layer 1: 意图路由层(Router) │
│ detect_intent() → 分类模型/GPT-4o-mini │
│ 职责:判断当前对话属于哪个领域 │
└────────────────────┬────────────────────────────┘
│ 触发 agent 切换
┌────────────────────▼────────────────────────────┐
│ Layer 2: Domain Agent 层(Hotel/Flight/...) │
│ 每个 Agent 有独立 SK Kernel + YAML Persona │
│ 职责:领域内的对话管理与工具调用决策 │
└────────────────────┬────────────────────────────┘
│ @kernel_function 调用
┌────────────────────▼────────────────────────────┐
│ Layer 3: 工具执行层(Tools) │
│ SQL 操作 / 向量检索 / 外部 API │
│ 职责:原子化业务操作,返回结构化数据 │
└─────────────────────────────────────────────────┘
意图路由机制深度拆解
What :detect_intent() 函数,对每一个用户 utterance(语音识别完成后)触发一次意图分类推断。
Why 要做成独立分类器而不让主模型自己判断:
- 延迟优势 :GPT-4o-mini
max_tokens=20的推断速度远快于完整的 GPT-4o 对话推理 - 精确性:专门的分类模型(fine-tuned)比 zero-shot 提示工程更稳定
- 解耦:路由逻辑与对话逻辑完全分离,互不干扰
双模式实现(生产 vs 开发):
python
# utility.py --- detect_intent 的双模式实现
async def detect_intent(conversation):
if INTENT_SHIFT_API_URL:
# 模式1:调用 Azure ML Online Endpoint 上的 fine-tuned 分类器
# 输入:对话文本(字符串)
# 输出:agent 名称字符串(如 "hotel_agent" / "flight_agent")
data = {
"input_data": {
"columns": ["input_string"],
"index": [0],
"data": [[conversation]] # AzureML 推理服务的标准输入格式
},
"params": {}
}
# ...HTTP 调用...
else:
# 模式2:GPT-4o-mini 零样本分类(开发环境 fallback)
messages = [
{"role": "system", "content": "You are a classifier model...
只能回复 hotel_agent 或 flight_agent"},
{"role": "user", "content": conversation}
]
response = await async_client.chat.completions.create(
model=AZURE_OPENAI_4O_MINI_DEPLOYMENT,
messages=messages,
max_tokens=20 # 强制模型只输出一个词,极省 token
)
意图切换的状态机
Agent 切换的整个流程是一个精心设计的状态机,核心状态字段如下:
python
session = {
"current_agent": ..., # 当前服务的 Agent 对象
"current_agent_kernel": ..., # 当前 Agent 的 SK Kernel(含工具集)
"target_agent_name": None, # 检测到的目标 Agent 名称(切换前暂存)
"transfer_conversation": False, # 是否正在切换
"active_response": False, # 是否有正在生成的回复(防并发冲突)
}
关键时序(以用户从酒店问题切到机票问题为例):
plain
用户说: "另外,我的机票能改签吗?"
↓
[Whisper 转录完成] → CONVERSATION_ITEM_INPUT_AUDIO_TRANSCRIPTION_COMPLETED 事件
↓
session["history"].add_user_message(transcript) # 先记录历史
↓
await self._detect_intent_change(session) # 异步意图检测
↓ (检测到 intent="flight_agent" ≠ current="hotel_agent")
session["target_agent_name"] = "flight_agent"
session["transfer_conversation"] = True
↓
await self._reinitialize_session(realtime_client, session)
├── 发送 input_audio_buffer.clear(清空音频缓冲,防止残留音频触发旧Agent)
├── 切换 current_agent 和 current_agent_kernel
├── 用新 Agent 的 persona 格式化 instructions(注入客户姓名/ID)
└── await realtime_client.update_session(settings, kernel=新Kernel)
↓
await realtime_client.send(RealtimeEvent("response.create")) # 用新Agent生成回复
防重复响应保护 :代码中的
active_responseflag 确保在已有一个响应正在生成时,不会重复触发response.create,避免 Agent 说话叠加。
防幻觉机制的三道防线
第一道:Persona YAML 的硬约束指令
yaml
# hotel_agent_profile.yaml
- Provide answers based solely on the facts from the search tool.
If there isn't enough information, inform the customer that you don't know.
- Do not generate answers that are not based on the search information.
这是一个写在 System Prompt 级别的"宪法约束",GPT-4o 接受指令训练,对此类约束服从度很高。
第二道:工具调用强制接地(Grounding)
python
# SK 的 FunctionChoiceBehavior.Auto() 设置
# 让模型在"有工具可用时优先调用工具"
function_choice_behavior=FunctionChoiceBehavior.Auto()
Agent 被训练成"先工具后回答"的行为模式:查政策必须先调 search_knowledgebase,查订单必须先调 load_user_*_info。
第三道:ChatHistory 截断防止上下文污染
python
# 只保留最近 3 轮对话
max_history_length = 3
init_history = ChatHistoryTruncationReducer(target_count=self.max_history_length)
过长的历史会引入早期的幻觉内容或错误信息,截断到 3 轮可以在保证对话连贯性的同时,防止**"历史污染"**。
训练数据生成(意图分类器)
这部分是整个方案中最被低估的工程亮点之一。
What :用 GPT-4(temperature=0.9,JSON 强制输出模式)批量合成 3253 条意图分类标注数据。
How(数据格式设计的巧妙之处):
python
# training_data_gen.ipynb --- 训练数据的两阶段设计
# 第一阶段:生成带 current_domain + intent_shift 的样本
{
"conversation_transcript": ["agent: ...", "user: ..."],
"current_domain": "flight_agent",
"intent_shift": "hotel_agent" # 或 "no_change"
}
# 第二阶段:转换为纯分类任务格式(去掉 current_domain,直接预测最终 intent)
{
"conversation": "agent: ...\nuser: ...",
"intent": "hotel_agent" # no_change → 转换为实际的 current_domain
}
关键的 Label 设计决策 :将 no_change 转换为 current_domain 的名字(比如 hotel_agent),让模型学会的是"当前应该由哪个 Agent 服务",而不是学"是否发生了切换"------这是一个更鲁棒的 formulation,避免了 binary classification 的局限性。
数据分布控制:
python
prompt = """This time, I need to generate a lot more examples in "no_change",
"general_agent" and "car_rental_agent" categories so focus only on these."""
作者注意到类别不平衡问题,通过在 prompt 中明确指定需要更多少数类样本来解决------这是 synthetic data 生成的标准技巧。
数据验证管道:
python
for d in data['training_data']:
# 四重验证:字段完整性 + 标签合法性 + domain 合法性
if ("conversation_transcript" in d and
"current_domain" in d and
"intent_shift" in d and
(d["intent_shift"] in agents or d["intent_shift"] == "no_change") and
d["current_domain"] in agents):
output_data.append(d)
全栈技术组件详解
Semantic Kernel 的角色
What :Microsoft 开源的 AI Orchestration SDK,在本项目中承担三个职责:
- 封装 Azure OpenAI Realtime WebSocket 连接(
AzureRealtimeWebsocket) - 管理
@kernel_function工具注册与 Function Calling 执行链 - 提供
ChatHistoryTruncationReducer用于历史截断
Why 选 SK 而不是 LangChain:项目是 Microsoft 官方出品,SK 是 Microsoft 自家的框架,与 Azure OpenAI 的集成更紧密,特别是 Realtime API 的支持在 SK 中是一等公民。
会话状态存储:优雅的双模式降级
python
# utility.py --- SessionState 类:优雅的生产/开发双模式
class SessionState:
def __init__(self):
AZURE_REDIS_ENDPOINT = os.getenv("AZURE_REDIS_ENDPOINT")
AZURE_REDIS_KEY = os.getenv("AZURE_REDIS_KEY")
if AZURE_REDIS_KEY:
# 生产模式:Redis(SSL 连接,端口 6380)
self.redis_client = redis.StrictRedis(
host=AZURE_REDIS_ENDPOINT, port=6380,
password=AZURE_REDIS_KEY, ssl=True)
else:
# 开发模式:内存字典
self.session_store: Dict[str, Dict] = {}
def set(self, key, value):
if self.redis_client:
# pickle 序列化 + base64 编码,使 Python 对象可存入 Redis
self.redis_client.set(key, base64.b64encode(pickle.dumps(value)))
else:
self.session_store[key] = value
设计模式洞察:这是**"Strategy Pattern + Graceful Degradation"**的完美结合。通过环境变量切换后端存储,上层代码零改动。
pickle+base64的序列化方式虽然不如 JSON 安全,但能完整保存ChatHistoryTruncationReducer对象的所有状态(包括内部的messages列表和截断配置)。
可观测性:OpenTelemetry 全链路
本项目的可观测性设计是工业级水准,支持三种导出模式的动态组合:
python
# rtmt.py --- 通过环境变量 TELEMETRY_SCENARIO 控制
# 支持逗号分隔的多场景:如 "application_insights,console"
TELEMETRY_SCENARIOS = os.getenv("TELEMETRY_SCENARIO", "console").split(",")
| 场景 | 用途 | 导出目标 |
|---|---|---|
console |
本地开发调试 | stdout |
application_insights |
生产监控 | Azure Monitor |
aspire_dashboard |
本地可视化仪表盘 | OTLP gRPC |
SK 专属过滤器:
python
handler.addFilter(logging.Filter("semantic_kernel"))
只将 semantic_kernel.* 命名空间的日志接入 OpenTelemetry,避免全量日志污染 trace,精准追踪 SK 框架内的每一次工具调用和 LLM 推理。
大白话费曼解释 (Feynman Explanation)
类比一:多 Agent 意图切换 = 医院分诊台 + 专科诊室
想象你去医院看病。一进门先到分诊台 (
detect_intent),护士简单问几句话,判断你是看内科还是外科。然后你被带到内科诊室 (hotel_agent),里面的医生(Anna)专门负责内科问题,有自己的病历系统(
Hotel_Tools)可以查你的住院记录、政策手册。突然你说:"医生,我的腿也有点问题"------分诊台护士立刻出现,把你的病历(对话历史)拿走,送到隔壁外科诊室(flight_agent),里面的医生(Maya)接手,直接看你之前的病历继续诊治。
全程你没有离开医院、没有重新挂号、病历也没有丢失------这就是"无感 Agent 切换"的本质。
关键点 :分诊台护士(意图分类器)是独立于诊室医生(主模型)的人,她只需要问 2 句话就能判断,不需要和你聊 10 分钟------这就是为什么用轻量小模型而不是用主模型来做路由的原因。
类比二:RAG 防幻觉 = 餐厅服务员的严格规则
餐厅服务员(AI Agent)被老板(Persona YAML)严格培训:
"回答客人问题前,必须先去查菜单(调用 search_knowledgebase 工具),不准凭空编答案!"客人问:"你们有素食选项吗?"
服务员不能拍脑袋说"有!有松茸炒饭!"------必须先去厨房拿今天的菜单(向量检索政策文档),把菜单上的内容读给客人听,如果菜单没写,就说"抱歉我不知道"。
向量检索就像菜单索引系统------你用语义"素食"去搜,它把最相关的菜品(top-3 政策条款)翻出来给你。服务员只能 cite 菜单上的内容,不能创作。
这就是 RAG 防幻觉的本质:把 LLM 的"创作自由"限制在工具返回的事实边界内。
降维打击与实战启发
Trick 1:轻量分类器解耦路由逻辑(可直接复用)
这是最值得"抄作业"的模式。任何多 Agent 系统都可以用这个模式:
python
# ==================== 可复用的意图路由模板 ====================
# 适用于任何需要在多个专业 Agent 之间路由的场景
AGENT_DESCRIPTIONS = {
"support_agent": "Handle technical support and bug reports",
"sales_agent": "Handle pricing, licensing, and purchase inquiries",
"billing_agent": "Handle invoices, refunds, and payment issues",
}
async def detect_intent_lightweight(conversation: str, current_agent: str) -> str:
"""
用 GPT-4o-mini + max_tokens=20 做极轻量的意图路由。
核心设计原则:
1. max_tokens=20:强制模型只输出 agent 名称,不废话
2. temperature 不设(默认1.0):分类任务反而受益于一点随机性(边界样本不会过拟合到某个固定答案)
3. 系统提示明确枚举所有可能输出,防止幻觉出不在列表中的 agent 名
"""
agent_list = "\n".join([f"- **{name}**: {desc}"
for name, desc in AGENT_DESCRIPTIONS.items()])
messages = [
{
"role": "system",
"content": f"""You are an intent classifier.
Based on the conversation, output ONLY the name of the most appropriate agent.
Possible agents:
{agent_list}
Output rules:
- Output ONLY the agent name, nothing else
- If the current topic matches the current agent ({current_agent}), output: {current_agent}
- Never output anything not in the list above"""
},
{"role": "user", "content": conversation}
]
response = await async_client.chat.completions.create(
model="gpt-4o-mini", # 关键:用小模型
messages=messages,
max_tokens=20 # 关键:强制短输出
)
intent = response.choices[0].message.content.strip()
# 防御性校验:如果模型输出了不在列表中的内容,保持当前 agent
return intent if intent in AGENT_DESCRIPTIONS else current_agent
Trick 2:LLM-as-Data-Generator + 自动质量过滤(意图分类器训练数据生成)
python
# ==================== 合成训练数据生成模板 ====================
# 核心思路:用强模型(GPT-4)生成数据,用规则过滤确保质量
import json
from openai import AzureOpenAI
from typing import List, Dict
VALID_LABELS = {"hotel_agent", "flight_agent", "car_rental_agent", "general_agent"}
def build_generation_prompt(target_label: str, n_samples: int = 10) -> str:
"""
关键技巧:在 prompt 中明确指定需要生成的类别,解决类别不平衡。
"""
return f"""Generate {n_samples} customer service conversation examples
where the final intent is: {target_label}
Each example must be JSON with keys: conversation_transcript, current_domain, intent_shift
Rules:
- conversation_transcript: list of 3-5 turns (agent/user alternating)
- current_domain: the domain being handled at start (can be different from intent_shift)
- intent_shift: "{target_label}" or "no_change" (if current_domain stays same)
- Include partial/incomplete user sentences to simulate real speech transcription
Output a JSON object with key "training_data" containing the list.
"""
def generate_and_filter(
client: AzureOpenAI,
model: str,
target_label: str,
n_batches: int = 10
) -> List[Dict]:
"""
批量生成 + 四重质量过滤:
1. JSON 格式合法性
2. 必需字段完整性
3. intent_shift 标签合法性
4. current_domain 合法性
"""
results = []
for _ in range(n_batches):
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": build_generation_prompt(target_label)}],
temperature=0.9, # 高 temperature 保证多样性
response_format={"type": "json_object"}, # 强制 JSON 输出,避免格式错误
)
try:
data = json.loads(response.choices[0].message.content)
for item in data.get("training_data", []):
# 四重过滤
if (
"conversation_transcript" in item and
"current_domain" in item and
"intent_shift" in item and
item["current_domain"] in VALID_LABELS and
(item["intent_shift"] in VALID_LABELS or item["intent_shift"] == "no_change")
):
results.append(item)
except (json.JSONDecodeError, KeyError) as e:
print(f"Skipping invalid batch: {e}")
continue
return results
def convert_to_classification_format(raw_data: List[Dict]) -> List[Dict]:
"""
关键的 Label 转换:将 no_change → current_domain 名称
使模型学会"当前该由哪个 agent 服务"而不是"是否发生了切换"
这个 formulation 更鲁棒,适合 fine-tuning 多分类器
"""
converted = []
for item in raw_data:
transcript = "\n".join(item["conversation_transcript"])
intent = (item["intent_shift"]
if item["intent_shift"] != "no_change"
else item["current_domain"]) # ← 核心转换逻辑
converted.append({
"conversation": transcript,
"intent": intent
})
return converted
Trick 3:双协程无阻塞 WebSocket 桥接模式
这个模式可复用于任何需要**"实时双向流量转发"**的场景(如 WebSocket proxy、音视频转码桥等):
python
# ==================== 可复用的双协程 WebSocket 桥接模板 ====================
# 适用于:任何需要在两个 WebSocket 之间做实时双向转发的场景
# 优点:asyncio.gather 让两个方向完全并发,互不阻塞
import asyncio
import json
from aiohttp import web
async def bidirectional_ws_bridge(
client_ws: web.WebSocketResponse, # 来自用户的 WebSocket
upstream_ws, # 连接到上游服务的 WebSocket
transform_client_to_upstream=None, # 可选:消息转换函数(client → upstream)
transform_upstream_to_client=None, # 可选:消息转换函数(upstream → client)
on_upstream_event=None # 可选:处理特殊上游事件的回调
):
"""
核心设计:两个独立的 async 协程并发运行,
通过 asyncio.gather 等待两者都完成(任一出错则全部取消)。
这比"轮询"模式延迟更低,比"线程"模式资源占用更少。
"""
async def forward_client_to_upstream():
"""方向1:客户端 → 上游,带可选转换"""
async for msg in client_ws:
if msg.type == web.WSMsgType.TEXT:
data = json.loads(msg.data)
if transform_client_to_upstream:
data = transform_client_to_upstream(data)
if data: # 转换后可能返回 None 表示过滤掉该消息
await upstream_ws.send_json(data)
elif msg.type in (web.WSMsgType.CLOSE, web.WSMsgType.ERROR):
break
async def forward_upstream_to_client():
"""方向2:上游 → 客户端,带可选转换和特殊事件处理"""
async for event in upstream_ws:
if on_upstream_event:
# 允许外部处理特殊事件(如记录历史、触发意图检测)
should_forward = await on_upstream_event(event, client_ws)
if not should_forward:
continue
if transform_upstream_to_client:
event = transform_upstream_to_client(event)
if event:
await client_ws.send_json(event)
# 关键:gather 确保两个方向同时运行,任一退出则另一也退出
await asyncio.gather(
forward_client_to_upstream(),
forward_upstream_to_client()
)
架构全链路流程图 (Mermaid)
|"PCM16 base64 frames\ninput_audio_buffer.append"| WS_ENDPOINT
PHONE --> ACS_WS
ACS_WS -->|"AudioData → input_audio_buffer.append\ncallerId as session_key"| WS_ENDPOINT
WS_ENDPOINT --> SESSION_STORE
SESSION_STORE -->|"transcription.completed\n触发意图检测"| DETECT
DETECT -->|"intent == flight_agent?"| FLIGHT_AGENT
DETECT -->|"intent == hotel_agent?"| HOTEL_AGENT
HOTEL_AGENT -->|"update_session\n+ Hotel_Tools Kernel"| AOAI_RT
FLIGHT_AGENT -->|"update_session\n+ Flight_Tools Kernel"| AOAI_RT
AOAI_RT -->|"Function Calling"| HOTEL_TOOLS
AOAI_RT -->|"Function Calling"| FLIGHT_TOOLS
AOAI_RT -->|"response.audio.delta\nPCM16 base64 frames"| WS_ENDPOINT
HOTEL_TOOLS --> HOTEL_DB
HOTEL_TOOLS --> HOTEL_VEC
FLIGHT_TOOLS --> FLIGHT_DB
FLIGHT_TOOLS --> FLIGHT_VEC
WS_ENDPOINT -->|"history.reduce()\npickle+base64"| REDIS
REDIS -->|"session resume\nrestore history"| SESSION_STORE
WS_ENDPOINT -->|"audio.delta frames"| WEB
WS_ENDPOINT -->|"response.audio.delta\n→ ACS AudioData"| ACS_WS
ACS_WS -->|"PCM24K audio\n+ StopAudio barge-in"| PHONE
Backend --> OTEL
OTEL --> APPINS
OTEL --> ASPIRE
style DETECT fill:#ff9,stroke:#f90,color:#000
style AOAI_RT fill:#9cf,stroke:#06f,color:#000
style REDIS fill:#f9f,stroke:#c0c,color:#000
style HOTEL_VEC fill:#cfc,stroke:#090,color:#000
style FLIGHT_VEC fill:#cfc,stroke:#090,color:#000 -->

总结:这个方案的真正价值
| 维度 | 本方案的水准 |
|---|---|
| 架构可扩展性 | ⭐⭐⭐⭐⭐ YAML 驱动,新增 Agent 零改代码 |
| 防幻觉可靠性 | ⭐⭐⭐⭐ 三道防线(Prompt约束 + 工具接地 + 历史截断),但**无 Citation 溯源** |
| 实时延迟 | ⭐⭐⭐⭐⭐ Server VAD + GPT-4o Realtime,<200ms token 延迟 |
| 工程成熟度 | ⭐⭐⭐⭐⭐ OpenTelemetry全链路 + Redis分布式状态 + 容器化部署 |
| 意图路由精度 | ⭐⭐⭐⭐ Fine-tuned + GPT-4o-mini双模式,3253条合成数据训练 |
| 知识检索深度 | ⭐⭐⭐ 纯向量检索,无BM25混合/Reranker,适合小规模KB |
最核心的启发 :这个项目展示了一个生产级 AI 系统的"正确打开方式"------不是堆砌最复杂的技术,而是在正确的地方用正确的技术:路由用小模型、生成用大模型、状态用 Redis、监控用 OpenTelemetry。架构的简洁性本身就是最高级的工程智慧。
参考
https://github.com/microsoft/multi-modal-customer-service-agent
好的,我来做一个从零开始、完整串联的详细讲解,确保小白也能一步步看懂整条链路。
精确SQL查询之load_user_reservation_info 完整调用链路详解
先用一个生活类比建立整体感知
想象你打电话给酒店客服:
你说:"我想查一下我的预订信息"
背后发生的事:
- 电话接线员(AI Agent "Anna")听到你说话 → 语音变成文字
- Anna 意识到需要查数据库 → 决定调用一个查询工具
- 查询工具拿着你的客户ID → 去数据库翻档案
- 数据库返回预订记录 → 工具把结果交回给 Anna
- Anna 用自然语言告诉你预订详情 → 语音回复给你
load_user_reservation_info就是上面第 3 步那个****"去数据库翻档案"****的工具。
数据库长什么样(地基)
首先要理解这个工具查的是什么数据库。数据库文件是 hotel.db(SQLite 格式),里面有两张表:
plain
【customers 表】------ 客户信息
┌──────────┬──────────┐
│ id │ name │
├──────────┼──────────┤
│ 12345 │ John Doe │ ← user_profile.json 里定义的测试用户
└──────────┴──────────┘
【reservations 表】------ 预订信息
┌─────┬─────────────┬──────────┬───────────┬──────────────┬───────────────┬────────┐
│ id │ customer_id │ hotel_id │ room_type │ check_in_date│check_out_date │ status │
├─────┼─────────────┼──────────┼───────────┼──────────────┼───────────────┼────────┤
│ 001 │ 12345 │ HTL_001 │ Deluxe │ 2024-03-01 │ 2024-03-05 │ booked │
│ 002 │ 12345 │ HTL_002 │ Suite │ 2024-04-10 │ 2024-04-12 │ booked │
└─────┴─────────────┴──────────┴───────────┴──────────────┴───────────────┴────────┘
这个函数的完整代码,逐行拆解
python
# hotel_plugins.py 第 180-200 行
@kernel_function( # ← 第1层:装饰器,让 Semantic Kernel 认识这个函数
name="load_user_reservation_info", # ← SK 注册的工具名(GPT-4o 调用时用这个名字)
description="Loads the hotel reservation for a user." # ← GPT-4o 靠这句描述判断要不要调用它
)
async def load_user_reservation_info(
self,
user_id: Annotated[str, "The user id."] # ← 第2层:参数注解,告诉 GPT-4o 需要传什么
) -> str: # ← 返回值是字符串(JSON 格式的字符串)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 第3层:实际的数据库查询
# 等价的 SQL 语句是:
# SELECT * FROM reservations
# WHERE customer_id = '12345'
# AND status = 'booked'
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
reservations = session.query(Reservation).filter_by(
customer_id=user_id, # ← 条件1:必须是这个用户的
status="booked" # ← 条件2:只查"已预订"状态,不查已取消的
).all() # ← .all() = 取出所有符合条件的记录(可能有多条)
# 如果什么都没查到,直接返回一句话
if not reservations:
return "Sorry, we cannot find any reservation information for you."
# 把查出来的数据库对象列表,转成 JSON 字符串返回给 GPT-4o
return json.dumps([
{
'room_type': reservation.room_type,
'hotel_id': reservation.hotel_id,
'check_in_date': reservation.check_in_date.strftime('%Y-%m-%d'), # 日期格式化
'check_out_date': reservation.check_out_date.strftime('%Y-%m-%d'),
'reservation_id': reservation.id,
'status': reservation.status
}
for reservation in reservations # ← 列表推导式,把每条记录都转成字典
])
四、完整的端到端调用流程(9 步图解)
plain
用户语音:"帮我查一下我的酒店预订"
│
▼ Step 1: GPT-4o Realtime API 把语音转文字(Whisper)
"帮我查一下我的酒店预订"
│
▼ Step 2: rtmt.py 收到转录完成事件,把文字加入对话历史
session["history"].add_user_message("帮我查一下我的酒店预订")
│
▼ Step 3: rtmt.py 触发 response.create,让 GPT-4o 思考如何回复
│
▼ Step 4: GPT-4o 读取对话历史 + Persona 指令,决定调用工具
│
│ GPT-4o 的内部推理(Function Calling):
│ "用户要查预订 → 我有 load_user_reservation_info 工具
│ 它需要 user_id → 从 session 上下文知道是 '12345'
│ → 发出工具调用请求"
│
▼ Step 5: Semantic Kernel 拦截 Function Call,执行 Python 函数
hotel_tools.load_user_reservation_info(user_id="12345")
│
▼ Step 6: SQLAlchemy 执行数据库查询
SELECT * FROM reservations WHERE customer_id='12345' AND status='booked'
│
▼ Step 7: 数据库返回结果,Python 函数序列化成 JSON 字符串
'[{"room_type": "Deluxe", "hotel_id": "HTL_001",
"check_in_date": "2024-03-01", "check_out_date": "2024-03-05",
"reservation_id": "001", "status": "booked"}]'
│
▼ Step 8: SK 把这个 JSON 字符串作为工具结果,返回给 GPT-4o
│
▼ Step 9: GPT-4o 读取工具结果,用自然语言生成最终回复
"您好 John!我查到您有一条预订记录:
入住豪华间,酒店编号 HTL_001,
入住日期 3月1日,退房日期 3月5日,
预订状态:已确认。请问还有什么需要帮助的吗?"
│
▼ 语音合成后播放给用户
customer_id 是怎么传进来的?(关键细节)
GPT-4o 怎么知道要传 user_id="12345"?
答案在 ****Persona 模板里:
yaml
# hotel_agent_profile.yaml
persona: |
You are Anna, a hotel customer service agent.
You are currently serving {customer_name}, whose ID is {customer_id}.
# ↑↑↑ 这里的 {customer_id} 在每次对话建立时被替换成真实值
在 rtmt.py 里,用户通过 URL 传入客户信息:
python
# rtmt.py 第 425-426 行
# 用户连接 WebSocket 时附带的参数
customer_name = request.query.get("customer_name", "John Doe")
customer_id = request.query.get("customer_id", "12345")
然后注入到 Persona:
python
# rtmt.py 第 220-226 行
def _format_instructions(self, agent: dict, session: dict) -> str:
template = agent.get("persona", "")
return template.format(
customer_name=session.get("customer_name", "John Doe"),
customer_id=session.get("customer_id", "12345")
# ↑ 把占位符替换成真实值,写入 GPT-4o 的 System Prompt
)
结果:GPT-4o 的 System Prompt 里有这么一句:
plain
"You are currently serving John Doe, whose ID is 12345."
所以当 GPT-4o 决定调用 load_user_reservation_info 时,它直接从 System Prompt 里知道 user_id = "12345",无需用户再说一遍。
所有酒店工具函数一览对比(完整版)
| 函数名 | 触发场景(用户说什么) | 需要的参数 | 实际执行的操作 | 返回结果示例 |
|---|---|---|---|---|
load_user_reservation_info |
"查我的预订" / 对话开始时主动问候 | user_id |
SELECT * FROM reservations WHERE customer_id=? AND status='booked' |
所有预订的 JSON 数组 |
check_reservation_status |
"我的预订状态怎么样" + 指定预订号 | reservation_id |
SELECT * FROM reservations WHERE id=? AND status='booked' |
单条预订的 JSON |
check_change_reservation |
"我想改一下入住日期,要多少钱?" | reservation_id + 新日期 + 新房型 |
不查数据库,直接返回固定费用 | "Changing will cost $50" |
confirm_reservation_change |
"好的,我确认改订" | reservation_id + 新日期 + 新房型 |
UPDATE 旧记录为 cancelled + INSERT 新记录 |
新预订确认信息 |
query_rooms |
"还有哪些房型可以选?" | hotel_id + 入退住日期 |
不查数据库,返回固定的三种房型 | 标准间/豪华间/套房列表 |
search_hotel_knowledgebase |
"宠物政策是什么?" / "几点可以入住?" | search_query(问题文本) |
向量检索政策文档(完全不碰 SQL) | Top-3 政策条款文本 |
可以直接运行的完整独立示例代码
下面这段代码把整个工具调用链路从零独立复现,不依赖 Semantic Kernel,你可以直接在本地运行理解:
python
"""
完整独立示例:模拟 load_user_reservation_info 的完整工作流
依赖:pip install sqlalchemy
"""
import json
from datetime import datetime
from sqlalchemy import create_engine, Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, relationship
# ═══════════════════════════════════════════════════
# 第一步:定义数据库结构(和项目代码完全一致)
# ═══════════════════════════════════════════════════
Base = declarative_base()
class Customer(Base):
__tablename__ = 'customers'
id = Column(String, primary_key=True)
name = Column(String)
reservations = relationship('Reservation', backref='customer')
class Reservation(Base):
__tablename__ = 'reservations'
id = Column(Integer, primary_key=True, autoincrement=True)
customer_id = Column(String, ForeignKey('customers.id'))
hotel_id = Column(String)
room_type = Column(String)
check_in_date = Column(DateTime)
check_out_date = Column(DateTime)
status = Column(String)
# ═══════════════════════════════════════════════════
# 第二步:创建内存数据库并插入测试数据
# ═══════════════════════════════════════════════════
engine = create_engine('sqlite:///:memory:') # 内存数据库,运行完自动消失
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
db = Session()
# 插入一个测试客户
db.add(Customer(id="12345", name="John Doe"))
# 插入两条预订记录
db.add(Reservation(
customer_id="12345",
hotel_id="HTL_001",
room_type="Deluxe",
check_in_date=datetime(2024, 3, 1),
check_out_date=datetime(2024, 3, 5),
status="booked"
))
db.add(Reservation(
customer_id="12345",
hotel_id="HTL_002",
room_type="Suite",
check_in_date=datetime(2024, 4, 10),
check_out_date=datetime(2024, 4, 12),
status="cancelled" # ← 注意:这条是 cancelled,不会被查出来
))
db.commit()
# ═══════════════════════════════════════════════════
# 第三步:完整复现 load_user_reservation_info 函数
# ═══════════════════════════════════════════════════
def load_user_reservation_info(user_id: str) -> str:
"""
完整复现项目中的工具函数
"""
print(f"\n📌 [工具被调用] load_user_reservation_info(user_id='{user_id}')")
print(f" 等价 SQL: SELECT * FROM reservations WHERE customer_id='{user_id}' AND status='booked'")
# 执行查询(只查 status='booked' 的记录)
reservations = db.query(Reservation).filter_by(
customer_id=user_id,
status="booked"
).all()
print(f" 查询结果:找到 {len(reservations)} 条记录")
if not reservations:
return "Sorry, we cannot find any reservation information for you."
result = json.dumps([
{
'room_type': r.room_type,
'hotel_id': r.hotel_id,
'check_in_date': r.check_in_date.strftime('%Y-%m-%d'),
'check_out_date': r.check_out_date.strftime('%Y-%m-%d'),
'reservation_id': r.id,
'status': r.status
}
for r in reservations
], indent=2, ensure_ascii=False)
return result
# ═══════════════════════════════════════════════════
# 第四步:模拟 GPT-4o Function Calling 的完整决策过程
# ═══════════════════════════════════════════════════
def simulate_gpt4o_function_calling(user_message: str, customer_id: str):
"""
模拟 GPT-4o 收到用户消息后,决定调用哪个工具的过程
(真实场景下这一步由 GPT-4o 自动完成)
"""
print(f"\n{'='*60}")
print(f"👤 用户说:{user_message}")
print(f"{'='*60}")
# GPT-4o 根据 description 判断要调用哪个工具
# "Loads the hotel reservation for a user."
# → 用户问的是预订信息 → 调用这个工具
print("\n🤖 GPT-4o 决策:")
print(" → 用户想查预订信息")
print(" → 我有 load_user_reservation_info 工具(描述:Loads the hotel reservation for a user)")
print(f" → System Prompt 告诉我当前用户 ID 是 '{customer_id}'")
print(f" → 发出 Function Call: load_user_reservation_info(user_id='{customer_id}')")
# 实际执行工具
tool_result = load_user_reservation_info(customer_id)
print(f"\n📦 [工具返回结果]:")
print(tool_result)
# GPT-4o 读取工具结果,生成自然语言回复
parsed = json.loads(tool_result)
r = parsed[0]
gpt_response = (
f"您好!我查到您有 {len(parsed)} 条有效预订:\n"
f" 入住 {r['room_type']} 房型,酒店编号 {r['hotel_id']}\n"
f" 入住日期:{r['check_in_date']},退房日期:{r['check_out_date']}\n"
f" 预订编号:{r['reservation_id']},状态:{r['status']}\n"
f"请问还有什么需要帮助的吗?"
)
print(f"\n💬 [GPT-4o 最终回复]:")
print(gpt_response)
return gpt_response
# ═══════════════════════════════════════════════════
# 运行示例
# ═══════════════════════════════════════════════════
if __name__ == "__main__":
# 场景1:正常查询(有预订记录)
simulate_gpt4o_function_calling(
user_message="帮我查一下我的酒店预订",
customer_id="12345"
)
# 场景2:查询不存在的用户
simulate_gpt4o_function_calling(
user_message="查一下我的预订",
customer_id="99999" # 数据库里没有这个用户
)
运行输出:
plain
============================================================
👤 用户说:帮我查一下我的酒店预订
============================================================
🤖 GPT-4o 决策:
→ 用户想查预订信息
→ 我有 load_user_reservation_info 工具(描述:Loads the hotel reservation for a user)
→ System Prompt 告诉我当前用户 ID 是 '12345'
→ 发出 Function Call: load_user_reservation_info(user_id='12345')
📌 [工具被调用] load_user_reservation_info(user_id='12345')
等价 SQL: SELECT * FROM reservations WHERE customer_id='12345' AND status='booked'
查询结果:找到 1 条记录 ← cancelled 那条被过滤了
📦 [工具返回结果]:
[
{
"room_type": "Deluxe",
"hotel_id": "HTL_001",
"check_in_date": "2024-03-01",
"check_out_date": "2024-03-05",
"reservation_id": 1,
"status": "booked"
}
]
💬 [GPT-4o 最终回复]:
您好!我查到您有 1 条有效预订:
入住 Deluxe 房型,酒店编号 HTL_001
入住日期:2024-03-01,退房日期:2024-03-05
预订编号:1,状态:booked
请问还有什么需要帮助的吗?
============================================================
👤 用户说:查一下我的预订
============================================================
...
📦 [工具返回结果]:
"Sorry, we cannot find any reservation information for you."
三个最容易踩坑的细节
坑1: status="booked"** 过滤条件**
python
# ✅ 项目代码:只查 booked 状态
.filter_by(customer_id=user_id, status="booked")
# 如果不加 status 过滤:
# 用户改了订单 → 旧订单变成 cancelled → 查询会把 cancelled 的也返回
# 导致 AI 告诉用户"您有2条预订",其中一条其实已经取消了!
坑2: session** 是模块级单例(线程安全隐患)**
python
# hotel_plugins.py 第 28 行
session = Session() # ← 模块加载时创建,全局共享
# ⚠️ 风险:多个用户并发时共用同一个 session
# 项目的规避方式:SQLite 本身是文件锁,对于演示场景够用
# 生产环境建议:每次请求创建新 session(用 with Session() as s:)
坑3: Annotated** 注解不是普通类型提示**
python
user_id: Annotated[str, "The user id."]
# ↑ ↑
# 实际类型 给 GPT-4o 看的参数说明
# Semantic Kernel 会把这个说明提取出来,
# 组装成 OpenAI Function Calling 的 JSON Schema,
# 让 GPT-4o 知道这个参数"是什么、填什么"
生成的 Function Schema 大概长这样(SK 自动生成,你不用手写):
json
{
"name": "load_user_reservation_info",
"description": "Loads the hotel reservation for a user.",
"parameters": {
"type": "object",
"properties": {
"user_id": {
"type": "string",
"description": "The user id."
}
},
"required": ["user_id"]
}
}
这就是 GPT-4o "看到工具说明书"的原始格式。<font style="color:#DF2A3F;">@kernel_function</font> 装饰器 + <font style="color:#DF2A3F;">Annotated</font> 注解共同完成了这份"说明书"的自动生成,只需要写 Python 函数,剩下的 SK 搞定。