SIP 变量系统完整说明
February 14, 2026 · View on GitHub
概述
Active-Call 的变量系统现在明确区分 SIP Headers 和 普通变量,提供更清晰的变量管理和模板渲染机制。
核心特性
1. 变量分类
SIP Headers
- 识别规则:通过
sip.extract_headers配置提取的 headers - 来源:
- 从 SIP INVITE 请求中提取(配置
sip.extract_headers) - 通过
<set_var key="X-xxx" value="..."/>动态设置
- 从 SIP INVITE 请求中提取(配置
- 访问方式:
{{ sip["X-Header-Name"] }}(必须使用字典语法) - 用途:SIP 协议相关的元数据
- 重要:Header 名称中的连字符会被 Jinja2 解析为减号,所以
{{ X-Header }}会失败
普通变量
- 识别规则:不以
X-或x-开头的变量 - 来源:
- 通过
<set_var key="var_name" value="..."/>设置 - 外部注入(API 调用时传入)
- 通过
- 访问方式:
{{ var_name }} - 用途:业务逻辑相关的数据
内置会话变量(v0.3.38+)
- 识别规则:系统自动注入,无需配置
- 来源:通话建立时自动生成
- 访问方式:
{{ session_id }}、{{ call_type }}等 - 变量列表:
session_id— 通话会话唯一标识call_type— 通话类型(sip/websocket/webrtc/b2bua)caller— 主叫方 SIP URI(仅 SIP 通话)callee— 被叫方 SIP URI(仅 SIP 通话)start_time— 通话开始时间(RFC 3339)
- 特性:不会覆盖外部传入的同名变量
2. 模板访问
在 Playbook 的 YAML 配置和 Prompt 中,可以使用以下语法:
---
llm:
greeting: "你好 {{ sip[\"X-Customer-Name\"] }},你的会员等级是 {{ member_level }}"
---
# Scene: main
客户编号:{{ sip["X-Customer-ID"] }}
业务类型:{{ business_type }}
关键点:
- ✅ SIP Headers 必须通过
sip字典访问:{{ sip["X-Header"] }} - ✅ 普通变量直接访问:
{{ variable_name }} - ❌ 错误:
{{ X-Header }}会被解析为减法运算
3. set_var 指令
LLM 可以在对话过程中动态设置变量:
设置 SIP Header
<set_var key="X-Call-Status" value="completed" />
<set_var key="X-Ticket-ID" value="TKT-12345" />
设置普通变量
<set_var key="user_name" value="张三" />
<set_var key="order_id" value="ORD-98765" />
批量设置(不推荐)
<set_var key="_hangup_headers" value='{"X-Key1":"val1","X-Key2":"val2"}' />
4. BYE Headers 渲染
挂断时,hangup_headers 模板可以访问所有变量:
---
sip:
extract_headers:
- "X-Customer-ID"
- "X-Session-Type"
hangup_headers:
# 访问 SIP Headers
X-Customer: "{{ sip[\"X-Customer-ID\"] }}"
X-Type: "{{ sip[\"X-Session-Type\"] }}"
# 访问普通变量
X-Call-Result: "{{ call_result }}"
X-Agent-Name: "{{ agent_name }}"
X-Duration: "{{ call_duration }}"
---
在对话中设置这些变量:
<set_var key="call_result" value="successful" />
<set_var key="agent_name" value="Alice" />
<set_var key="call_duration" value="180" />
动态场景 Prompt 渲染(v0.3.38+)
从 v0.3.38 开始,通过 <set_var> 设置的变量可以在场景切换时动态渲染到目标场景的 Prompt 中。
工作原理
- 解析 Playbook 时,原始模板保存在
Scene.raw_prompt中 - 每次
<goto>切换场景时,使用当前extras重新渲染 - 内置变量、SIP Headers、set_var 变量均可用
- 渲染失败时自动回退到已有 Prompt
示例
# Scene: collect
请收集用户意向。收集后输出 <set_var key="intent" value="..." /> 然后 <goto scene="confirm" />
# Scene: confirm
会话ID:{{ session_id }}
用户意向:{{ intent }}
请确认以上信息。
切换到 confirm 场景时,{{ intent }} 和 {{ session_id }} 会被动态替换为实际值。
详细说明参见 Playbook Advanced Features
实现细节
变量存储
所有变量(SIP Headers 和普通变量)都存储在 ActiveCall.call_state.extras: HashMap<String, serde_json::Value> 中。
SIP Headers 识别
系统在源头(提取 headers 时)就标记哪些是 SIP Headers:
- 配置驱动:在 Playbook 中通过
sip.extract_headers明确指定要提取的 SIP Headers - 自动标记:提取后,系统自动在
extras中存储一个_sip_header_keys列表 - 为什么需要
_sip_header_keys:- 问题根源:SIP Header 名称包含连字符(如
X-Customer-ID),Jinja2 会将{{ X-Customer-ID }}解析为X - Customer - ID(减法运算) - 解决方案:将 SIP Headers 放入
sip字典,使用{{ sip["X-Customer-ID"] }}访问 - 实现关键:需要在渲染时知道哪些变量是 SIP Headers,因此在提取时存储
_sip_header_keys列表 - 运行时缓存:
_sip_header_keys是配置的运行时缓存,连接"配置"和"渲染"两个阶段
- 问题根源:SIP Header 名称包含连字符(如
// 在 handler.rs 中过滤并标记 SIP headers
if let Some(allowed_headers) = &sip_config.extract_headers {
let mut state = active_call.call_state.write().await;
if let Some(extras) = &mut state.extras {
filter_headers(extras, allowed_headers);
// 存储 SIP header keys 列表
let header_keys: Vec<String> = extras.keys()
.filter(|k| !k.starts_with('_'))
.cloned()
.collect();
extras.insert("_sip_header_keys", serde_json::to_value(&header_keys)?);
}
}
模板渲染
Playbook 解析时
// 从存储的 keys 列表构建 sip 字典
let sip_header_keys: Vec<String> = vars
.get("_sip_header_keys")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default(); // 没有配置 extract_headers 时为空
let mut sip_headers = HashMap::new();
for key in &sip_header_keys {
if let Some(value) = vars.get(key) {
sip_headers.insert(key.clone(), value.clone());
}
}
context.insert("sip", serde_json::to_value(&sip_headers)?);
BYE Headers 渲染时
// 获取存储的 SIP header keys
let sip_header_keys: Vec<String> = state.extras
.as_ref()
.and_then(|e| e.get("_sip_header_keys"))
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default(); // 没有配置时为空
// sip 字典和所有变量都可访问
for (k, v) in extras {
if k.starts_with('_') { continue; } // 跳过内部 keys
context.insert(k.clone(), v.clone());
if sip_header_keys.contains(k) {
sip_headers.insert(k.clone(), v.clone());
}
}
context.insert("sip", serde_json::to_value(&sip_headers)?);
测试覆盖
Playbook 模块测试
test_sip_dict_access_with_hyphens- 测试 SIP Headers 通过 sip 字典访问test_sip_dict_only_contains_sip_headers- 验证 sip 字典只包含 SIP Headerstest_sip_dict_mixed_access- 测试混合访问 SIP Headers 和普通变量test_sip_dict_case_insensitive- 测试大小写敏感性test_sip_dict_empty_context- 边界条件测试
Handler 模块测试
test_set_var_with_sip_headers- 测试批量设置 SIP Headerstest_set_var_individual_sip_header- 测试单个 SIP Header 设置test_bye_headers_with_all_variables- 测试 BYE Headers 访问所有变量
使用场景
场景 1:呼入客户服务
---
sip:
extract_headers:
- "X-Customer-ID"
- "X-Customer-Level"
hangup_headers:
X-Call-Result: "{{ result }}"
X-Satisfaction: "{{ satisfaction }}"
llm:
greeting: "{{ sip[\"X-Customer-Level\"] }} 客户您好!"
---
# Scene: main
您的客户编号是 {{ sip["X-Customer-ID"] }}
场景 2:外呼营销
---
sip:
hangup_headers:
X-Contact-Result: "{{ contact_result }}"
X-Interest-Level: "{{ interest }}"
X-Follow-Up: "{{ need_followup }}"
---
# Scene: main
<set_var key="contact_result" value="answered" />
客户对产品表示感兴趣 <set_var key="interest" value="high" />
需要后续跟进 <set_var key="need_followup" value="yes" />
场景 3:工单系统集成
---
sip:
extract_headers:
- "X-Ticket-ID"
- "X-Priority"
hangup_headers:
X-Resolution: "{{ resolution }}"
X-Handled-By: "{{ agent }}"
---
# Scene: main
工单 {{ sip["X-Ticket-ID"] }} 优先级:{{ sip["X-Priority"] }}
问题已解决 <set_var key="resolution" value="solved" />
<set_var key="agent" value="AI-Agent-01" />
最佳实践
-
命名约定
- SIP Headers:推荐使用
X-前缀(RFC 规范),如X-Customer-ID - 普通变量:使用下划线分隔,如
customer_name、order_status
- SIP Headers:推荐使用
-
配置原则
- 在
sip.extract_headers中明确列出需要提取的 headers - 不要依赖命名约定(X- 前缀),让配置驱动一切
- 在
-
访问模式
- 始终通过
sip字典访问 SIP Headers:{{ sip["X-Header"] }} - 直接访问普通变量:
{{ variable_name }} - 重要:包含连字符的变量必须使用字典访问,否则会被解析为减法
- 始终通过
-
BYE Headers
- 在
hangup_headers中可以访问所有类型的变量 - 优先使用普通变量存储业务逻辑数据
- 使用 SIP Headers 传递协议层面的信息
- 在
-
性能考虑
set_var是轻量级操作,可以频繁使用- 避免使用
_hangup_headers批量设置,优先使用单个set_var
-
为什么之前没有发现问题
- 在引入
sip字典之前,代码中从未直接在模板中使用过{{ X-Header-Name }}这样的语法 - 实际使用时要么通过 API 参数传入(不经过模板),要么使用不含连字符的变量名
- 当用户尝试在模板中使用
{{ X-Header }}时才发现 Jinja2 会将其解析为减法运算
- 在引入
迁移指南
问题的由来
在 v0.3.38 之前,系统中虽然存在 SIP Headers(如 X-Customer-ID),但:
- 从未在模板中直接引用过这些包含连字符的变量
- Headers 主要用于内部传递,不参与模板渲染
- 当用户首次尝试在 Playbook 中使用
{{ X-Header }}时,发现 Jinja2 将其解析为X - Header(减法)
从旧版本迁移
问题场景:
llm:
greeting: "Hello {{ X-Customer-Name }}" # ❌ 错误:被解析为 X - Customer - Name
错误信息:
Jinja2 template error: undefined variable 'X'
或
Jinja2 template error: unsupported operand type(s) for -: 'str' and 'str'
解决方案:
sip:
extract_headers:
- "X-Customer-Name" # 必须先配置提取
llm:
greeting: "Hello {{ sip[\"X-Customer-Name\"] }}" # ✅ 正确:字典访问
向后兼容性
- 普通变量(不含连字符)的访问方式保持不变
- 只有包含连字符的 SIP Headers 需要更新访问方式
- 所有测试已更新以覆盖新的访问模式