第1939篇:对话状态机的设计——用有限状态机管理复杂的多轮交互流程
第1939篇:对话状态机的设计——用有限状态机管理复杂的多轮交互流程
做AI客服两年多,我越来越觉得,多轮对话系统最难的不是让AI说话,而是让AI在正确的时机说正确的话,并且能记住它现在处于哪个业务流程的哪一步。
举个例子:用户发起一个退款申请。这个流程有好几步:确认订单信息 → 核实退款原因 → 提交申请 → 等待审核 → 通知结果。每一步的AI行为都不一样,每一步需要收集的信息不一样,可以做的操作也不一样。如果没有清晰的状态管理,就会出现:用户在确认订单阶段,AI突然跳去收集退款原因;或者申请已经提交了,AI还在让用户填原因。
用普通的if-else逻辑来管理这种多步骤流程,到了三四步之后代码就成屎山了。更麻烦的是,AI的行为是概率性的,你无法精确预测它什么时候会往哪个方向走,if-else根本管不住。
有限状态机(FSM)是一个被低估的解决方案。
有限状态机的基础概念
先简单回顾一下有限状态机是什么,如果你熟悉的话可以跳过。
有限状态机有以下几个要素:
- 状态(State):系统当前所处的阶段
- 转移(Transition):从一个状态到另一个状态的条件
- 动作(Action):进入状态时或转移发生时执行的操作
- 初始状态和终止状态
对话FSM的特殊之处在于:状态转移的触发条件不是精确的事件,而是AI根据对话内容的判断。这就需要在传统FSM的基础上引入"意图匹配"的概念。
设计一个退款申请的对话状态机
以退款申请流程为例,先把状态图画出来:
Java状态机框架实现
// 状态枚举
public enum RefundFlowState {
IDLE("空闲", "等待用户发起退款请求"),
COLLECTING_ORDER_ID("收集订单号", "请用户提供需要退款的订单号"),
VERIFYING_ORDER("验证订单", "正在验证订单信息"),
ORDER_NOT_FOUND("订单未找到", "提示用户订单不存在"),
ORDER_NOT_REFUNDABLE("订单不可退款", "解释为什么不能退款"),
COLLECTING_REASON("收集退款原因", "请用户说明退款原因"),
CONFIRMING_REFUND("确认退款信息", "向用户展示退款摘要并确认"),
SUBMITTING_REFUND("提交退款申请", "正在提交退款到系统"),
REFUND_SUBMITTED("退款已提交", "告知用户申请已提交"),
SUBMIT_FAILED("提交失败", "告知用户提交失败并引导重试");
private final String displayName;
private final String description;
}
// 状态转移条件
@FunctionalInterface
public interface TransitionCondition {
/**
* 评估是否满足转移条件
* @param userMessage 用户消息
* @param flowContext 当前流程上下文
* @return 转移条件的满足程度(0.0-1.0)
*/
double evaluate(String userMessage, RefundFlowContext flowContext);
}
// 状态转移定义
@Data
@Builder
public class StateTransition {
private final RefundFlowState from;
private final RefundFlowState to;
private final TransitionCondition condition;
private final double threshold; // 满足条件的最低分数
private final TransitionAction action; // 转移时执行的动作
private final int priority; // 同一源状态有多个转移时的优先级
}状态机的核心实现
@Component
public class RefundFlowStateMachine {
private final IntentClassifier intentClassifier;
private final EntityExtractor entityExtractor;
private final OrderService orderService;
private final RefundService refundService;
// 注册所有状态转移
private final List<StateTransition> transitions;
@PostConstruct
public void initTransitions() {
transitions = List.of(
// IDLE -> COLLECTING_ORDER_ID:检测到退款意图
StateTransition.builder()
.from(IDLE)
.to(COLLECTING_ORDER_ID)
.condition((msg, ctx) -> intentClassifier.score(msg, "refund_request"))
.threshold(0.7)
.action(this::onEnterCollectingOrderId)
.priority(1)
.build(),
// COLLECTING_ORDER_ID -> VERIFYING_ORDER:提取到有效订单号
StateTransition.builder()
.from(COLLECTING_ORDER_ID)
.to(VERIFYING_ORDER)
.condition((msg, ctx) -> {
String orderId = entityExtractor.extractOrderId(msg);
if (orderId == null) return 0.0;
ctx.setOrderId(orderId);
return 1.0;
})
.threshold(0.9)
.action(this::onEnterVerifyingOrder)
.priority(1)
.build(),
// COLLECTING_ORDER_ID -> IDLE:用户取消
StateTransition.builder()
.from(COLLECTING_ORDER_ID)
.to(IDLE)
.condition((msg, ctx) -> intentClassifier.score(msg, "cancel_action"))
.threshold(0.7)
.action(this::onCancelRefund)
.priority(2)
.build(),
// VERIFYING_ORDER -> COLLECTING_REASON:验证通过
// 这个转移比较特殊:是系统主动触发,不是用户消息触发
StateTransition.builder()
.from(VERIFYING_ORDER)
.to(COLLECTING_REASON)
.condition((msg, ctx) -> ctx.isOrderVerified() ? 1.0 : 0.0)
.threshold(0.9)
.action(this::onEnterCollectingReason)
.priority(1)
.build(),
// COLLECTING_REASON -> CONFIRMING_REFUND:用户提供了退款原因
StateTransition.builder()
.from(COLLECTING_REASON)
.to(CONFIRMING_REFUND)
.condition((msg, ctx) -> {
String reason = extractRefundReason(msg, ctx);
if (reason.length() < 5) return 0.0;
ctx.setRefundReason(reason);
ctx.setRefundReasonCode(classifyReason(reason));
return 0.9;
})
.threshold(0.6)
.action(this::onEnterConfirmingRefund)
.priority(1)
.build(),
// CONFIRMING_REFUND -> SUBMITTING_REFUND:用户确认
StateTransition.builder()
.from(CONFIRMING_REFUND)
.to(SUBMITTING_REFUND)
.condition((msg, ctx) -> intentClassifier.score(msg, "confirm_yes"))
.threshold(0.7)
.action(this::onEnterSubmittingRefund)
.priority(1)
.build(),
// CONFIRMING_REFUND -> COLLECTING_REASON:用户想修改原因
StateTransition.builder()
.from(CONFIRMING_REFUND)
.to(COLLECTING_REASON)
.condition((msg, ctx) -> intentClassifier.score(msg, "modify_reason"))
.threshold(0.7)
.action(null)
.priority(2)
.build()
);
}
/**
* 处理用户消息,执行状态转移
*/
public StateMachineResult process(String userMessage, RefundFlowContext context) {
RefundFlowState currentState = context.getCurrentState();
// 找出当前状态所有可能的转移
List<StateTransition> applicableTransitions = transitions.stream()
.filter(t -> t.getFrom() == currentState)
.sorted(Comparator.comparingInt(StateTransition::getPriority))
.collect(Collectors.toList());
// 评估每个转移条件
StateTransition selectedTransition = null;
double bestScore = 0;
for (StateTransition transition : applicableTransitions) {
double score = transition.getCondition().evaluate(userMessage, context);
if (score >= transition.getThreshold() && score > bestScore) {
bestScore = score;
selectedTransition = transition;
}
}
if (selectedTransition != null) {
// 执行状态转移
RefundFlowState newState = selectedTransition.getTo();
log.info("状态转移:{} -> {}(分数:{:.2f})", currentState, newState, bestScore);
// 执行转移动作
if (selectedTransition.getAction() != null) {
selectedTransition.getAction().execute(context);
}
// 更新状态
context.setCurrentState(newState);
context.addStateHistory(currentState, newState, userMessage);
// 生成状态提示
String stateHint = generateStateHint(newState, context);
return StateMachineResult.builder()
.previousState(currentState)
.currentState(newState)
.transitioned(true)
.stateHint(stateHint)
.build();
}
// 没有匹配的转移,停留在当前状态
// 生成提示帮助用户完成当前步骤
String stuckHint = generateStuckHint(currentState, context);
return StateMachineResult.builder()
.previousState(currentState)
.currentState(currentState)
.transitioned(false)
.stateHint(stuckHint)
.build();
}
// 各状态的进入动作
private void onEnterVerifyingOrder(RefundFlowContext context) {
// 同步验证订单(实际应用中可能需要异步)
String orderId = context.getOrderId();
try {
OrderInfo order = orderService.getOrder(orderId);
if (order == null) {
context.setOrderVerified(false);
context.setVerificationError("ORDER_NOT_FOUND");
context.setCurrentState(ORDER_NOT_FOUND);
return;
}
if (!order.isRefundable()) {
context.setOrderVerified(false);
context.setVerificationError("NOT_REFUNDABLE");
context.setNotRefundableReason(order.getNotRefundableReason());
context.setCurrentState(ORDER_NOT_REFUNDABLE);
return;
}
context.setOrderVerified(true);
context.setOrderInfo(order);
} catch (Exception e) {
log.error("订单验证失败", e);
context.setVerificationError("SERVICE_ERROR");
}
}
private void onEnterSubmittingRefund(RefundFlowContext context) {
try {
RefundResult result = refundService.applyRefund(
context.getOrderId(),
context.getRefundReasonCode(),
context.getRefundReason()
);
if (result.isSuccess()) {
context.setRefundApplicationId(result.getApplicationId());
context.setCurrentState(REFUND_SUBMITTED);
} else {
context.setSubmitError(result.getErrorMessage());
context.setCurrentState(SUBMIT_FAILED);
}
} catch (Exception e) {
log.error("退款提交失败", e);
context.setSubmitError("系统异常,请稍后重试");
context.setCurrentState(SUBMIT_FAILED);
}
}
// 生成当前状态的提示信息
private String generateStateHint(RefundFlowState state, RefundFlowContext context) {
return switch (state) {
case COLLECTING_ORDER_ID ->
"请告诉模型:用户想申请退款,需要询问用户要退哪个订单的单号。";
case VERIFYING_ORDER ->
"请告诉模型:正在验证订单信息,稍等片刻。";
case ORDER_NOT_FOUND ->
"请告诉模型:订单" + context.getOrderId() + "不存在,请友好地提示用户确认订单号是否正确,并询问是否重新输入。";
case ORDER_NOT_REFUNDABLE ->
"请告诉模型:该订单无法退款,原因:" + context.getNotRefundableReason() + "。请友好解释并提供其他帮助选项。";
case COLLECTING_REASON ->
"请告诉模型:订单" + context.getOrderId() + "验证通过,金额" + context.getOrderInfo().getAmount() + "元。请询问用户退款原因。";
case CONFIRMING_REFUND ->
String.format("请告诉模型:向用户确认退款信息。订单:%s,金额:%s元,原因:%s。请用户确认是否提交。",
context.getOrderId(), context.getOrderInfo().getAmount(), context.getRefundReason());
case REFUND_SUBMITTED ->
"请告诉模型:退款申请已成功提交,申请编号" + context.getRefundApplicationId() + "。告知用户预计处理时间(1-3个工作日)。";
case SUBMIT_FAILED ->
"请告诉模型:退款提交失败,原因:" + context.getSubmitError() + "。请友好告知用户,并建议稍后重试或联系人工客服。";
default -> null;
};
}
}与AI对话引擎的集成
状态机要和AI对话引擎集成,核心是把状态机的提示注入到System Prompt里:
@Service
public class StateMachineAwareConversationEngine {
private final RefundFlowStateMachine stateMachine;
private final LlmClient llmClient;
public String handleUserMessage(String userMessage, RefundFlowContext flowContext) {
// 第一步:执行状态机,决定状态转移
StateMachineResult smResult = stateMachine.process(userMessage, flowContext);
// 第二步:构建包含状态提示的System Prompt
String systemPrompt = buildSystemPrompt(smResult, flowContext);
// 第三步:调用AI生成回复
List<Message> messages = buildMessages(systemPrompt, flowContext, userMessage);
String aiResponse = llmClient.chat(messages);
// 第四步:更新对话历史
flowContext.addTurn(userMessage, aiResponse);
return aiResponse;
}
private String buildSystemPrompt(StateMachineResult smResult, RefundFlowContext context) {
StringBuilder sb = new StringBuilder();
// 基础角色定义
sb.append("你是一个专业的退款处理客服,帮助用户完成退款申请流程。\n\n");
// 注入当前状态信息
sb.append("【当前流程状态】\n");
sb.append("当前步骤:").append(context.getCurrentState().getDisplayName()).append("\n");
sb.append("说明:").append(context.getCurrentState().getDescription()).append("\n\n");
// 注入状态机的提示
if (smResult.getStateHint() != null) {
sb.append("【本轮处理指引】\n");
sb.append(smResult.getStateHint()).append("\n\n");
}
// 注入已收集的信息(让AI知道现在知道了什么)
if (context.getOrderId() != null) {
sb.append("【已确认信息】\n");
sb.append("订单ID:").append(context.getOrderId()).append("\n");
if (context.getOrderInfo() != null) {
sb.append("订单金额:").append(context.getOrderInfo().getAmount()).append("元\n");
}
if (context.getRefundReason() != null) {
sb.append("退款原因:").append(context.getRefundReason()).append("\n");
}
sb.append("\n");
}
// 注入不可做的事情(防止AI越权)
sb.append("【注意事项】\n");
sb.append("- 不要跳过当前步骤去执行未来步骤\n");
sb.append("- 不要在用户确认之前就提交退款申请\n");
sb.append("- 如果用户问了与退款流程无关的问题,简短回答后引导回退款流程\n");
return sb.toString();
}
}并发和持久化
在实际生产环境里,流程上下文需要持久化(用户关闭页面后再回来,流程还在),而且可能有并发问题:
@Service
public class FlowContextRepository {
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
// 流程上下文在Redis中保存24小时
private static final long CONTEXT_TTL_HOURS = 24;
public void save(String sessionId, RefundFlowContext context) {
try {
String key = "flow_context:" + sessionId;
String json = objectMapper.writeValueAsString(context);
redisTemplate.opsForValue().set(key, json, CONTEXT_TTL_HOURS, TimeUnit.HOURS);
} catch (JsonProcessingException e) {
log.error("保存流程上下文失败", e);
}
}
public Optional<RefundFlowContext> load(String sessionId) {
String key = "flow_context:" + sessionId;
String json = redisTemplate.opsForValue().get(key);
if (json == null) return Optional.empty();
try {
return Optional.of(objectMapper.readValue(json, RefundFlowContext.class));
} catch (JsonProcessingException e) {
log.error("加载流程上下文失败", e);
return Optional.empty();
}
}
// 乐观锁更新,防止并发写入
public boolean updateWithVersion(String sessionId, RefundFlowContext context) {
String key = "flow_context:" + sessionId;
long expectedVersion = context.getVersion();
// Lua脚本:只有版本号匹配时才更新
String script = """
local current = redis.call('GET', KEYS[1])
if current == false then
return 0
end
local data = cjson.decode(current)
if data.version ~= tonumber(ARGV[1]) then
return 0
end
redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])
return 1
""";
context.setVersion(expectedVersion + 1);
try {
String newJson = objectMapper.writeValueAsString(context);
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
List.of(key),
String.valueOf(expectedVersion),
newJson,
String.valueOf(CONTEXT_TTL_HOURS * 3600)
);
return Long.valueOf(1).equals(result);
} catch (Exception e) {
log.error("更新流程上下文失败", e);
return false;
}
}
}FSM设计的几个经验
做了这么多状态机设计,有几点实战经验分享:
1. 状态要反映业务阶段,不要反映技术步骤
比如"正在调用退款API"这种技术状态不应该出现在状态机里,用户可感知的业务阶段才是状态。
2. 状态数量控制在10个以内
状态太多了,维护成本指数级上升,而且AI注入的提示也会变得很长。如果发现状态超过10个,考虑把复杂流程拆成多个子状态机。
3. 每个状态的出口不超过5个
同样是可维护性的问题。出口太多说明这个状态的职责不够单一。
4. 给状态机加超时
用户中途放弃了,流程不应该永远挂着。给每个非终止状态设置超时,超时后回到IDLE:
// 检查流程超时
if (context.getLastActivityTime().isBefore(Instant.now().minus(30, ChronoUnit.MINUTES))) {
context.setCurrentState(IDLE);
context.addSystemNote("流程因超时自动重置");
}5. 打印状态转移日志
这是最重要的调试工具。每次状态转移都要记录:从哪个状态、到哪个状态、触发条件的分数、用户说了什么。没有这个,调试状态机问题非常痛苦。
有限状态机引入了一定的开发复杂度,但它给对话系统带来的可预测性、可维护性、可调试性是其他方案比不上的。对于需要严格按步骤走的业务流程——退款、下单、预约、注册——状态机几乎是必选方案。
