假设 Voice Agent 调用 CRM 创建投诉工单,等待两秒后收到超时。系统应该重试吗?

直接重试,可能创建两张工单;不重试,用户的投诉又可能根本没有进入 CRM。最麻烦的是第三种情况:CRM 已经创建成功,只是响应在返回途中丢了。此时 Voice Agent 看到的是失败,CRM 里却已经有了一张工单。

这才是 Voice Agent 接入 CRM 时真正难处理的分叉。Function Call 能生成一段结构化参数,只代表模型表达了“想创建工单”;它不等于 CRM 已经受理,更不等于后续状态已经回到会话系统。

我的判断是:不要让语音对话线程同步承担 CRM 写入结果。更稳妥的做法,是把工具调用变成一条可恢复、可审计的业务命令:服务端校验后先将命令和 Outbox 事件放进同一笔本地事务,再由独立投递器访问 CRM,最后把工单编号、状态和下一步动作回流给 Voice Agent。

本文给出一个可以直接运行的 Python 项目,使用标准库、SQLite 和本地 MockCRM 验证这条链路。项目已在 Python 3.9.6 实际运行。Mock 只用于验证机制,不连接任何真实 CRM,也不能据此推断厂商接口性能。

目录

超时以后,系统其实面对三种状态

一次 create_case 超时,至少可能对应三种事实:

  1. 请求没有到达 CRM;
  2. 请求到达了,但 CRM 创建失败;
  3. CRM 已创建成功,响应没有回到调用方。

如果日志只有一句 create_case timeout,这三种状态无法区分。也因此,我不建议把 CRM 调用直接塞进语音响应线程,然后用一次 HTTP 返回决定要对用户说什么。

语音线程最该做的是尽快给出一个诚实状态:

已记录您的诉求,工单正在提交;提交结果确认后会继续更新。

只有拿到 CRM 返回的 case_id,系统才应该说“工单已创建”。accepteddeliveredcompleted 是三个不同阶段,不能为了话术顺滑把它们合并成一个“成功”。

本文示例使用下面这条状态链:

工具调用通过校验

命令与 Outbox 同事务落库

CRM 返回工单编号

超时、429 或 5xx

重试成功

字段或权限错误

转人工核查

写入会话反馈快照

queued

pending

delivered

retry_wait

dead_letter

manual_review

feedback_ready

这条链路在 Voice Agent 中承担的是业务闭环,不是对话生成。ASR、LLM 和 TTS 决定系统如何听、如何理解、如何说;CRM 集成决定用户的诉求有没有变成可跟踪的业务动作。

第一步不是调 CRM,而是收紧工具边界

最小版本通常会把模型生成的参数直接转发给 CRM:

payload = llm_tool_call.arguments
crm.create_case(payload)

这段代码看起来省事,问题也很集中:

  • 模型可能生成 CRM 并不存在的字段;
  • 手机号、地址等内容可能还没有经过用户确认;
  • 当前调用者未必拥有创建或修改工单的权限;
  • 自由文本里可能混入访问令牌、身份证号等不应进入工单的内容;
  • CRM 字段升级以后,历史工具调用无法判断使用的是哪一版契约。

所以工具输入必须先成为服务端业务命令。示例中的请求如下:

{
  "conversation_id": "conv-20260801-001",
  "task_id": "create-complaint-01",
  "action_version": "v1",
  "issue_type": "complaint",
  "summary": "包裹显示已签收,但用户未收到",
  "customer_phone": "13800138000",
  "confirmed_fields": ["customer_phone"],
  "actor_scopes": ["crm:case:create"]
}

这里有两个看似啰嗦、实际上很有用的字段。

confirmed_fields 表示哪些关键字段已经得到用户明确确认。中文客服电话里,手机号、地址、订单号很容易受 ASR 同音字、数字规整和用户改口影响。模型提取到了字段,不代表用户确认了字段。示例会直接拒绝把未确认手机号写入 CRM。

actor_scopes 则让权限判断留在服务端。模型可以请求工具,但不能给自己增加 crm:case:create 权限。示例为了方便复现把权限和确认结果写在请求 JSON 里;生产环境必须从可信鉴权上下文和服务端会话状态读取,不能相信模型自己提交的 actor_scopesconfirmed_fields。赔付、关闭投诉、修改地址等高风险动作还应使用更细的权限范围,必要时转人工。

核心校验在 demo/crm_demo/models.py

if REQUIRED_SCOPE not in self.actor_scopes:
    raise ValidationError(f"missing required scope: {REQUIRED_SCOPE}")

if self.customer_phone and "customer_phone" not in self.confirmed_fields:
    raise ValidationError(
        "customer_phone cannot be written before explicit confirmation"
    )

生产环境还需要 JSON Schema 或 Pydantic 一类的契约工具,但原则不变:模型只提出受控动作,服务端决定这个动作能不能执行。

幂等键能挡住重复请求,但还不够

用户重复说“对,帮我提交”、语音网关重连、任务队列重新消费,都可能让相同工具调用到达两次。

示例用下面三个字段生成幂等键:

conversation_id + task_id + action_version

对应代码不是简单拼接,而是生成固定长度摘要:

source = ":".join(
    (self.conversation_id, self.task_id, self.action_version)
)
digest = hashlib.sha256(source.encode("utf-8")).hexdigest()[:24]
return f"case:{digest}"

其中 task_id 必须表示同一个业务动作,不能在每次重试时重新生成。如果第一次请求使用 create-complaint-01,第二次重试变成一个新 UUID,数据库看到的就是两条不同命令,幂等设计等于失效。

action_version 解决的是用户真的修改了内容。例如用户先确认投诉“未收到包裹”,随后补充“门卫已代收,不需要投诉”。新的业务动作应提升版本或撤销旧任务,而不是继续复用旧结果。

本地数据库对 idempotency_key 设置唯一约束。同一个请求再次进入时,接口返回原命令状态,不再插入第二条 Outbox:

existing = connection.execute(
    "SELECT * FROM commands WHERE idempotency_key = ?",
    (command.idempotency_key,),
).fetchone()

if existing:
    return dict(existing), True

但只在本地做幂等仍不完整。最危险的窗口是:

CRM 已创建工单
-> 本地进程尚未标记 delivered
-> 进程崩溃
-> Outbox 再次投递

真实 CRM 最好也接受同一个幂等键;如果接口不支持,至少应写入唯一的 external_request_id,并提供按该字段查询、对账的能力。否则 Outbox 能保证“不丢”,却不能严格保证“不重”。

为什么工具调用和 Outbox 必须一起提交

另一个常见实现是先保存业务命令,再向消息队列发送事件:

save_command(command)
publish_event(command)

如果第一步成功、第二步失败,数据库里会留下一个永远没人投递的命令。把顺序调过来也不行:事件先发出、命令后保存失败,消费者会拿到一条没有业务记录的事件。

示例使用 SQLite 演示 Transactional Outbox。命令和待投递事件在同一事务中写入:

connection.execute("BEGIN IMMEDIATE")

connection.execute(
    """
    INSERT INTO commands (
        idempotency_key, conversation_id, task_id,
        action_version, status, created_at, updated_at
    ) VALUES (?, ?, ?, ?, 'queued', ?, ?)
    """,
    values,
)

connection.execute(
    """
    INSERT INTO outbox (
        idempotency_key, payload_json, status, next_attempt_at
    ) VALUES (?, ?, 'pending', ?)
    """,
    outbox_values,
)

两条记录要么一起成功,要么一起回滚。投递器只扫描 pending 和到期的 retry_wait,CRM 调用不再阻塞 Voice Agent 的语音循环。

这也改变了工具接口的语义。第一次提交返回 HTTP 202 Accepted 比返回“工单创建成功”更准确:服务端接受了命令,但远端 CRM 还没有确认。重复请求命中相同幂等键时,则返回已有状态和 reused: true

重试要看错误类型,不能只看失败次数

“失败就重试三次”听起来有容错能力,实际上会把很多错误放大。

网络超时、连接断开、HTTP 429 和部分 5xx 通常可以重试,但应采用指数退避、设置最大次数,并尊重 CRM 返回的 Retry-After。具体是否重试仍要按厂商接口语义判断。字段校验失败、权限不足、租户不匹配等 4xx,不应该原样重放。重放一百次也不会让错误的字段自动变对。

示例把异常分成两类:

try:
    result = crm.create_case(payload, idempotency_key)
except TransientCRMError as error:
    if attempts + 1 >= max_attempts:
        store.mark_dead_letter(outbox_id, idempotency_key, str(error))
    else:
        delay = retry_base_seconds * (2 ** attempts)
        store.mark_retry(outbox_id, idempotency_key, str(error), delay)
except PermanentCRMError as error:
    store.mark_dead_letter(outbox_id, idempotency_key, str(error))
else:
    store.mark_delivered(outbox_id, idempotency_key, result)

MockCRM 支持两个故障开关:

  • transient_once:第一次模拟超时,第二次成功;
  • permanent:模拟上游字段校验失败,事件进入 dead_letter

死信不是“把错误藏起来”。示例会把命令状态改为 manual_review,并在反馈表写入“转人工核查 CRM 写入”。生产环境还需要告警、人工处理入口和补偿操作,不能让死信只停留在数据库里。

数据回流不是把 CRM 档案塞给模型

工单创建成功以后,系统真正需要回流给下一轮对话的字段并不多:

{
  "conversation_id": "conv-20260801-001",
  "case_id": "CRM-0001",
  "status": "open",
  "next_action": "等待人工客服处理"
}

这份快照足以支持下一次服务:“您的工单 CRM-0001 当前处于处理中,下一步由人工客服跟进。”

不建议把 CRM 中的完整客户档案直接放进 LLM 上下文。模型真正需要的是当前任务相关、经过权限过滤、仍在有效期内的数据。租户信息、内部客服备注、历史敏感投诉和无关联系方式应由服务端白名单控制。

审计日志也不应该复制完整 Tool Call。示例只保存:

  • 会话编号;
  • 问题类型;
  • 摘要长度;
  • 脱敏后的手机号;
  • 幂等键、事件类型和结果。

手机号 13800138000 在审计记录中会变成 *******8000,投诉摘要正文不会重复落进审计表。真实系统还应区分业务留存与调试日志的保留周期,并对读取审计记录本身进行授权。

把完整项目跑起来

项目使用 Python 3.9 及以上版本,只依赖标准库:

CSDN-CRM数据回流/
├── article.md
├── README.md
├── demo/
│   ├── sample_payloads/
│   │   └── create_case.json
│   └── crm_demo/
│       ├── models.py
│       ├── redaction.py
│       ├── store.py
│       ├── crm_client.py
│       ├── service.py
│       ├── server.py
│       └── demo_scenario.py
└── tests/
    └── test_crm_outbox.py

在项目根目录直接运行一次完整场景:

python3 -m demo.crm_demo.demo_scenario

场景会提交同一工具调用两次,第一次投递模拟超时,第二次投递成功。关键输出如下:

{
  "tool_result": {
    "status": "queued",
    "reused": false
  },
  "duplicate_result": {
    "status": "queued",
    "reused": true
  },
  "first_delivery": [
    {"status": "retry_wait"}
  ],
  "second_delivery": [
    {"status": "delivered", "case_id": "CRM-0001"}
  ],
  "feedback": {
    "idempotency_key": "case:80c4c19a5b5d4c22885144a5",
    "case_id": "CRM-0001",
    "status": "open",
    "next_action": "等待人工客服处理"
  }
}

也可以启动本地 HTTP 服务:

python3 -m demo.crm_demo.server --port 8080 --db crm_demo.sqlite3

另开终端提交工具调用,再手动触发一次 Outbox 投递:

curl -sS \
  -H 'Content-Type: application/json' \
  --data @demo/sample_payloads/create_case.json \
  http://127.0.0.1:8080/tool-calls/create-case

curl -sS -X POST \
  http://127.0.0.1:8080/admin/drain-outbox

curl -sS \
  'http://127.0.0.1:8080/feedback?conversation_id=conv-20260801-001'

/admin/drain-outbox 只是为了让本地演示容易观察。生产环境应由后台 Worker、任务队列或定时调度器执行,并限制管理接口权限。

六项测试到底防什么回归

运行:

python3 -m unittest discover -s tests -v

当前项目实际执行通过 6 项测试:

test_duplicate_tool_call_creates_one_outbox_event ... ok
test_transient_failure_retries_without_duplicate_case ... ok
test_permanent_failure_enters_dead_letter_and_manual_review ... ok
test_successful_delivery_produces_feedback ... ok
test_unconfirmed_phone_is_rejected ... ok
test_audit_log_masks_phone_and_omits_summary_text ... ok

这些测试验证的是机制,不是性能。

重复请求测试防止同一个业务动作生成两条 Outbox;暂时性失败测试检查重试后只有一个 CRM 工单;永久失败测试确保错误不会无限重试,并能进入人工核查;回流测试确认下一轮对话能读取工单编号;最后两项分别守住关键字段确认和日志脱敏边界。

还没有真实 CRM 时,至少可以把这些系统行为固定下来。以后替换 MockCRM,相同测试可以继续约束适配器,新增的集成测试再验证厂商鉴权、字段映射、限流和回调。

上线前应该记录哪些指标

平均响应时间不足以描述 CRM 回流质量。我会优先记录以下指标,并按 crm_vendoraction_typeerror_class 和版本分组:

指标 口径 用途
工具调用接受率 通过 Schema、确认状态和权限校验的请求数 / 全部工具请求数 区分模型参数问题与 CRM 问题
重复抑制数 命中已有幂等键的请求数 观察重连、重放与重复确认
CRM 投递成功率 最终 delivered 的事件数 / 到期投递事件数 判断业务闭环是否完成
重试恢复率 重试后 delivered 的事件数 / 进入 retry_wait 的事件数 验证暂时性故障恢复能力
死信率 dead_letter 事件数 / 投递事件数 暴露字段、权限和适配器问题
写入到回流延迟 feedback.updated_at - command.created_at 衡量用户多久能看到确定结果
未确认字段拦截数 因关键字段未确认被拒绝的请求数 观察 ASR 与确认流程质量

延迟至少看 P50、P95 和 P99。CRM 偶发限流、网络抖动和队列堆积通常不会在平均值里显眼,却会直接造成一部分用户长时间收不到工单确认。

日志应贯穿同一个 conversation_idtask_ididempotency_key 和上游请求 ID。否则某条工单虽然最后创建成功,排障人员也很难把它和原始语音会话、工具调用及重试记录连起来。

本文没有真实 CRM 样本,因此不提供成功率或延迟数字。项目输出只能证明状态转换和测试断言按设计工作,不能当作生产基准。

换成真实 CRM 时还有三道边界

本地 Demo 与生产系统之间,至少还有三处不能省略。

第一,替换 MockCRM 时要保留适配器边界。不同 CRM 的字段、鉴权、限流和错误码差异很大,不要让这些差异渗入对话编排层。适配器负责把统一命令映射为厂商请求,并把响应归一成 case_idstatusnext_action

第二,解决“远端成功、本地未知”。CRM 若支持幂等请求头,应把本地幂等键原样传递;如果不支持,就写入唯一外部请求号并实现查询对账。只增加重试次数解决不了这个不确定窗口。

第三,多 Worker 消费 Outbox 时要先认领事件。当前 Demo 故意保持单 Worker,便于观察状态;生产环境需要数据库行锁、租约或消息队列,避免两个 Worker 同时发送同一条事件。

还有回流更新。工单从 open 变成 assignedwaiting_customerclosed,可以通过 Webhook 或轮询更新反馈快照。Webhook 同样要验签、去重、检查事件版本,不能因为回调来自 CRM 就默认可信。

对中文客服来说,地址、手机号、金额、订单号还要经过字段级确认。用户打断后改口时,旧工具调用必须取消或提升 action_version;投诉、赔付、身份争议和权限不足不能因为 CRM 接口“能调用”就自动处理。

几个实施中容易误解的问题

有 Function Call,为什么还要单独做工具服务?

Function Call 解决的是参数表达,不负责鉴权、业务校验、幂等、重试、审计和数据留存。让模型直接接触 CRM 凭证,会把系统边界变得不可控。

Outbox 能保证工单绝不重复吗?

不能单独保证。Outbox 保证本地业务命令与待投递事件不会一个成功、一个丢失。要覆盖“CRM 已成功、本地未确认”的窗口,还需要远端幂等键或查询对账机制。

CRM 写入失败时,Voice Agent 应该怎么说?

在只有 queuedretry_wait 时,只能说“已记录,正在提交”,不能虚构工单编号。进入 dead_letter 或关键字段无法确认时,应转人工,并把已经确认的上下文一并交接。

数据回流一定要放进 LLM 上下文吗?

不一定。确定性的工单编号、状态和下一步可以先由业务模板表达;只有需要解释或组合多项信息时才交给 LLM。这样既减少敏感信息暴露,也避免模型改写确定事实。

闪电智能 Voice Agent 在这条链路中的工程重点,不是让模型“更会调接口”,而是让每次业务动作有身份、有状态、能恢复、可追踪。只有工具调用、CRM 写入和反馈快照能沿同一个幂等键对上,工单创建才算真正进入了客服闭环。

参考资料

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐