LabHub
开始
学习 学习路径 课程

语音 AI 智能体 — 会听、会查、会说的流水线

用状态机实现预约智能体 — 确认·重试·转人工

在 LabHub 中继续学习

目标

把电话预约智能体做成状态机:没有确认问题就不预约,只对只读工具的超时重试,没听懂或者用户要找人工时,就附上概括转交出去。

为什么重要

语音智能体的事故出在工具上。不可撤销的行为在没有确认的情况下发生,或者把超时的请求重发一遍,导致预约变成了两个。本实验的工具是 voicekit.clinic.ClinicAPI——一个可以把故障作为计划表放进去的模拟预约 API——用户的话则由 voicekit.nlu.parse 转换成槽位。评分器会从你的 /root/voice/agent/agent.py 中载入 VoiceAgent,亲自运行九个场景(故障计划 + 用户的话),并且依据状态和工具调用记录而不是话语的措辞来判定。

步骤

  1. 向 ClinicAPI 注入故障,并把每个工具是否不可撤销、以及哪些错误值得重试,写入 /root/voice/agent/tools.json。
  2. 用 LLM(json_schema)和规则解析器提取 8 个句子的意图,并生成 /root/voice/agent/intents.jsonl 和正确数 /root/voice/agent/intent_acc.json。
  3. 创建带有 VoiceAgent(api, sleep) 和 handle(text) 的 /root/voice/agent/agent.py,让它只有在确认问题得到“是”之后才预约。
  4. 处理确认问题中的“不是”(如果有新的时间,就以该时间重新确认)。
  5. 对只读工具(find_slots)的超时,以 0.5 秒、1.0 秒的间隔最多再调用两次,仍然不行就以 tool_failure 转交。
  6. 用户要找人工时以 user_request 转交,连续两次没听懂时以 not_understood 转交,转交时概括已填入的槽位。
  7. 预约工具的拒绝(没有空位)要重新查找空闲时间并询问,而超时则不要重发,直接转交。
  8. 把运行九个场景得到的轮次记录保存到 /root/voice/agent/traces.jsonl。

参考

写下工具的性质

用 voicekit.clinic 的 TOOLS 和 ClinicAPI(plan={"find_slots": ["timeout", "ok"], "book": ["taken"]}) 亲自触发故障,并在 /root/voice/agent/tools.json 中写下 tools(每个工具名称对应 {"irreversible": true/false})和 retryable(值得重试的错误名称列表)。

TOOLS 的值是(是否不可撤销,说明)。ToolTimeout 是“没有响应”,ToolError 是“被拒绝了”——哪一种值得重发?

LLM 和规则,谁能判对意图

8 个句子——“I'd like to book an appointment for Tuesday”(book)、“CAN I CANCEL MY VISIT ON FRIDAY”(cancel)、“YEAH THAT WORKS”(yes)、“NO THAT'S NOT RIGHT”(no)、“CAN I TALK TO A REAL PERSON”(human)、“WHAT TIME DO YOU CLOSE TODAY”(hours)、“TEN THIRTY IN THE MORNING”(inform)、“MY DOG ATE THE REMOTE”(unknown)——按这个顺序,把用 intent 作为八个值的 enum 的 json_schema 向 LLM 询问的结果(llm)和 voicekit.nlu.parse 的结果(rule),以 {"text", "llm", "rule"} 的形式写入 /root/voice/agent/intents.jsonl,并把与括号内的正确答案相符的数量,以 {"llm": n, "rule": m} 的形式写入 /root/voice/agent/intent_acc.json。

voice-llm up 之后,用 voicekit.llm.chat(messages, max_tokens=40, json_schema=schema)。格式由语法强制,但内容要看模型。如果结果很奇怪,那正是这一步想要看到的。

只有得到确认之后才预约

在 /root/voice/agent/agent.py 中创建 VoiceAgent(api, sleep=time.sleep, max_retries=2, backoff=(0.5, 1.0)) 和 handle(text)。用 parse 填充槽位(day、time、name),按日期 → (用 find_slots 查找并给出空闲时间)时间 → 姓名的顺序询问空槽位,然后在 CONFIRM 中复述,只有听到“是”(intent yes)时才调用 api.book(day, time, name),并进入 DONE。评分:在五轮对话和一次说完的两轮对话中,book 都恰好在最后一轮出现一次。

设置一个决定“接下来要问什么”的函数(_next),即使一次说出了多个槽位,也可以用同一段代码处理。如果时间不在给出的空闲时间(offered)里,就重新给出。轮到问姓名时,如果解析器没能提取姓名,就把话里的最后一个词当作姓名。

确认时的“不是”就是修改

在 CONFIRM 中,当 intent 为 no 时:如果话里有新的时间或日期,就只改那个槽位并重新确认(CONFIRM);如果没有任何信息,就清空时间,并重新给出空闲时间。无论哪种情况,都不调用 book。评分:在“…ten a m…”→“no, make it two thirty”→“yes”中,book 以 14:30 在最后一轮调用一次。

parse('no, make it two thirty') 会同时给出 intent no 和 time 14:30。如果一听到 no 就无条件回到起点,就会丢弃用户刚刚说出的信息。

对只读工具的超时进行重试

如果 find_slots 抛出 ToolTimeout,就用 self.sleep(backoff[시도 번호])(占位符为尝试序号)依次休息 0.5 秒、1.0 秒,最多重新调用 max_retries(2)次。三次都失败,就把状态设为 HANDOFF,并把 handoff.reason 设为 tool_failure。评分:只有第一次调用超时时,调用两次,休息时间为 [0.5],进入 ASK_TIME;三次都失败时,调用三次,休息时间为 [0.5, 1.0],进入 HANDOFF。

必须把 sleep 作为构造函数的参数接收,评分器才能不去真正等待,而是记录“打算休息多久”。让它可以被测试,也是设计的一部分。

转交给人工——附带概括

在任何状态下,只要 intent 是 human,就立即转为 HANDOFF(user_request)。如果 intent 是 unknown,先请用户再说一遍,连续第二次时就转为 HANDOFF(not_understood)。听懂之后,把次数清为 0。转交时,把目前为止填入的 day、time、name 放进 handoff.summary。评分:在“hmm”→“blah blah”中,第二轮转交;在“Can I book on Friday”→“can I talk to a person”中,概括里要有 friday。

在询问姓名(ASK_NAME)时,即使解析器给出 unknown,也可能是用户只说了名字——此时算作听懂了。

不可撤销的工具失败时

如果 book 抛出 ToolError(时段已满),就不要重发,而是清空时间,用 find_slots 重新查找并给出空闲时间(ASK_TIME)。如果 book 抛出 ToolTimeout,就不要重发,转为 HANDOFF(tool_failure)。评分:没有空位之后,以新的时间 14:30 重新得到确认,book 共两次(10:00 失败,14:30 成功);预约超时时,book 一次之后转为 HANDOFF。

预约的超时不是“没成功”,而是“不知道”。服务器可能已经创建了预约,只是响应来得晚——重发的话,预约就会变成两个。

九个场景的轮次记录

用你的 agent.py 运行九个场景(happy · one_shot · confirm_no · retry_ok · retry_fail · not_understood · wants_human · taken · book_timeout——故障计划和用户的话,与各步骤任务中的评分说明相同),并把每一轮的 {"scenario", "turn", "user", "state", "say", "tools"(이번 턴에 부른 도구 이름), "outcomes"}(其中 tools 的内容为本轮调用的工具名称)写入 /root/voice/agent/traces.jsonl。评分器会把同样的场景重新运行一遍进行核对。

工具记录以(名称、参数、结果)的形式累积在 api.calls 中。用每一轮前后的长度之差,只提取本轮调用的内容。这份记录就是智能体的回归测试资料。