第2125篇:对话式AI的状态机设计——给多轮对话建立工程化的骨架
第2125篇:对话式AI的状态机设计——给多轮对话建立工程化的骨架
适读人群:构建复杂多轮对话系统的工程师 | 阅读时长:约20分钟 | 核心价值:用状态机思维管理对话流程,解决对话意图混乱、上下文丢失、流程无法回溯的问题
"用户填到第三步突然问了个无关的问题,AI就懵了,完全忘了前面收集了什么信息。"
这是构建复杂业务对话时最常见的问题。比如一个贷款申请助手,需要按顺序收集:姓名 → 收入 → 房产 → 提交。用户在收入这步突然问"你们利率是多少",如果AI没有状态管理,它可能回答了利率问题之后,完全忘记还要继续收集信息,或者把新的问题和之前的上下文混在一起处理错误。
问题的根源:大多数对话系统是"无状态"的——每次都发所有历史给LLM,让LLM自己理解"现在到哪儿了"。这在简单场景勉强可行,但在复杂多步骤业务流程中会不断出错。
状态机能给对话带来可预期的工程化控制。
为什么对话需要状态机
/**
* 无状态 vs 有状态对话设计
*
* ===== 无状态方案(常见做法)=====
*
* 实现:把所有历史消息发给LLM,让LLM"记住"当前在哪个步骤
*
* 问题:
* 1. 依赖LLM理解对话进度,不可控
* - 用户一句题外话就能让LLM"忘记"当前步骤
* - 无法保证按顺序完成必要步骤
*
* 2. 无法精确控制流程分支
* - 用户已经是VIP,跳过某些验证步骤
* - 用户信息不完整,需要回到某步重新收集
*
* 3. 上下文污染
* - 第1步收集的信息,到第5步LLM可能已经"忘记"了
* - 需要靠System Prompt不断提醒,成本高且不可靠
*
* ===== 有状态方案(状态机)=====
*
* 实现:明确定义对话的状态、转移条件、每个状态的行为
*
* 好处:
* 1. 精确控制:每个步骤的行为由代码定义,不依赖LLM的"理解"
* 2. 可回溯:用户说"我想修改刚才填的信息",能精确回到对应状态
* 3. 可测试:对话流程可以像代码一样单元测试
* 4. 数据完整性:在提交前确认所有必要字段都已收集
*
* 适用场景:
* - 表单填写型对话(收集信息完成业务)
* - 有明确步骤的业务流程(申请、预约、投诉)
* - 需要和后端系统交互的对话(查询、提交、确认)
*
* 不适用场景:
* - 自由问答(知识库问答、闲聊)
* - 创意生成(写作、分析)
*/状态机核心定义
/**
* 对话状态机框架
*
* 用枚举定义状态,用接口定义每个状态的行为
*/
// 对话状态定义(以贷款申请为例)
public enum LoanApplicationState {
GREETING, // 欢迎状态(初始)
COLLECTING_NAME, // 收集姓名
COLLECTING_INCOME, // 收集收入信息
COLLECTING_ASSETS, // 收集资产信息
REVIEW, // 信息确认
SUBMIT_PENDING, // 等待提交确认
SUBMITTED, // 已提交
INTERRUPTED, // 被中断(用户问了其他问题)
ABANDONED // 流程放弃
}
// 状态上下文(贯穿整个对话的数据)
@Data
@Builder
public class LoanApplicationContext {
private String sessionId;
private String userId;
// 已收集的业务数据
private String applicantName;
private String monthlyIncome;
private String incomeSource;
private String assetDescription;
// 状态机元数据
private LoanApplicationState currentState;
private LoanApplicationState previousState; // 用于从中断恢复
private int retryCount; // 当前状态的重试次数
private LocalDateTime stateEnteredAt;
// 对话历史(简短版,用于LLM上下文)
private List<String> conversationSummary;
public boolean isComplete() {
return applicantName != null && monthlyIncome != null && assetDescription != null;
}
}
/**
* 状态处理器接口
*
* 每个状态有独立的处理逻辑
*/
public interface StateHandler {
/**
* 处理用户输入,返回AI响应和下一个状态
*/
StateTransitionResult handle(String userInput, LoanApplicationContext context);
/**
* 进入该状态时的提示语
*/
String getEntryPrompt(LoanApplicationContext context);
/**
* 该状态能处理哪些输入类型
*/
List<InputType> getSupportedInputTypes();
enum InputType {
TEXT, // 普通文本
CONFIRMATION, // 是/否确认
BACK_REQUEST, // 要求返回上一步
CANCEL_REQUEST, // 取消整个流程
HELP_REQUEST, // 请求帮助
OFF_TOPIC // 与当前流程无关的问题
}
}
@Data
@Builder
public class StateTransitionResult {
private String responseText; // 对用户的响应
private LoanApplicationState nextState; // 下一个状态
private boolean stateChanged; // 是否发生了状态转移
private Map<String, Object> extractedData; // 从输入中提取的数据
}具体状态处理器实现
/**
* 收集姓名状态处理器
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class CollectingNameStateHandler implements StateHandler {
private final ChatLanguageModel llm;
private final UserIntentClassifier intentClassifier;
@Override
public StateTransitionResult handle(String userInput, LoanApplicationContext context) {
// 首先判断用户输入的意图
UserIntent intent = intentClassifier.classify(userInput, context);
// 处理通用意图(与当前步骤无关)
switch (intent) {
case CANCEL -> {
return StateTransitionResult.builder()
.responseText("好的,已为您取消本次申请。如需重新申请,随时告诉我。")
.nextState(LoanApplicationState.ABANDONED)
.stateChanged(true)
.build();
}
case HELP -> {
// 回答帮助问题,但不改变状态
String helpResponse = answerHelp(userInput, context);
return StateTransitionResult.builder()
.responseText(helpResponse + "\n\n请继续:您的姓名是?")
.nextState(LoanApplicationState.COLLECTING_NAME)
.stateChanged(false)
.build();
}
case OFF_TOPIC -> {
// 题外问题:先回答,再引导回来
String answer = answerOffTopic(userInput);
return StateTransitionResult.builder()
.responseText(answer + "\n\n回到我们的申请:请告诉我您的全名?")
.nextState(LoanApplicationState.COLLECTING_NAME)
.stateChanged(false)
.build();
}
}
// 提取姓名
String extractedName = extractName(userInput);
if (extractedName == null) {
// 提取失败,重试
int retryCount = context.getRetryCount() + 1;
context.setRetryCount(retryCount);
if (retryCount >= 3) {
// 多次失败,请求人工介入
return StateTransitionResult.builder()
.responseText("抱歉,我无法理解您提供的姓名信息。建议您联系客服人工处理。" +
"客服电话:400-xxx-xxxx")
.nextState(LoanApplicationState.ABANDONED)
.stateChanged(true)
.build();
}
return StateTransitionResult.builder()
.responseText("请提供您的真实全名(例如:张三),这将用于身份核实。")
.nextState(LoanApplicationState.COLLECTING_NAME)
.stateChanged(false)
.build();
}
// 姓名提取成功,转到下一步
return StateTransitionResult.builder()
.responseText(String.format("好的,%s。接下来请告诉我您的月收入情况(税前),可以是大概数字。", extractedName))
.nextState(LoanApplicationState.COLLECTING_INCOME)
.stateChanged(true)
.extractedData(Map.of("applicantName", extractedName))
.build();
}
private String extractName(String userInput) {
// 用LLM提取姓名
String prompt = """
从以下用户输入中提取中文姓名,只返回姓名本身,如果没有明确的姓名返回null。
输入:%s
姓名(只返回姓名或null):
""".formatted(userInput);
try {
String result = llm.generate(prompt).trim();
if ("null".equalsIgnoreCase(result) || result.isEmpty()) return null;
// 验证:中文姓名通常2-4个字
if (result.length() >= 2 && result.length() <= 5 &&
result.matches("[\\u4e00-\\u9fa5]+")) {
return result;
}
return null;
} catch (Exception e) {
return null;
}
}
private String answerHelp(String question, LoanApplicationContext context) {
return "在这一步,我需要收集您的真实全名用于贷款申请的身份核实。";
}
private String answerOffTopic(String question) {
// 用LLM简短回答题外问题
try {
return llm.generate("请简短回答(不超过50字):" + question);
} catch (Exception e) {
return "这个问题我稍后为您解答。";
}
}
@Override
public String getEntryPrompt(LoanApplicationContext context) {
return "您好!欢迎申请我们的贷款产品。首先,请告诉我您的全名?";
}
@Override
public List<InputType> getSupportedInputTypes() {
return List.of(InputType.TEXT, InputType.HELP_REQUEST,
InputType.OFF_TOPIC, InputType.CANCEL_REQUEST);
}
}状态机引擎
/**
* 对话状态机引擎
*
* 协调各状态处理器,管理状态转移
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ConversationStateMachine {
private final Map<LoanApplicationState, StateHandler> stateHandlers;
private final LoanApplicationContextRepository contextRepo;
/**
* 处理用户输入
*/
public String processInput(String sessionId, String userInput) {
// 加载或创建会话上下文
LoanApplicationContext context = loadOrCreateContext(sessionId);
log.info("处理输入: sessionId={}, state={}, input={}",
sessionId, context.getCurrentState(),
userInput.substring(0, Math.min(50, userInput.length())));
// 获取当前状态的处理器
StateHandler handler = stateHandlers.get(context.getCurrentState());
if (handler == null) {
log.error("未找到状态处理器: state={}", context.getCurrentState());
return "系统内部错误,请稍后重试";
}
// 处理输入
StateTransitionResult result = handler.handle(userInput, context);
// 更新上下文
if (result.isStateChanged()) {
context.setPreviousState(context.getCurrentState());
context.setCurrentState(result.getNextState());
context.setStateEnteredAt(LocalDateTime.now());
context.setRetryCount(0);
log.info("状态转移: {} → {}", context.getPreviousState(), context.getCurrentState());
}
// 保存提取的数据
if (result.getExtractedData() != null) {
applyExtractedData(context, result.getExtractedData());
}
// 如果状态变了,检查新状态是否有自动触发的入口提示
String response = result.getResponseText();
// 持久化上下文
contextRepo.save(context);
return response;
}
/**
* 用户请求返回上一步
*/
public String handleBackRequest(String sessionId) {
LoanApplicationContext context = loadOrCreateContext(sessionId);
if (context.getPreviousState() == null) {
return "这已经是第一步了,无法返回。";
}
// 清除当前步骤收集的数据
clearCurrentStepData(context);
context.setCurrentState(context.getPreviousState());
context.setPreviousState(null);
context.setStateEnteredAt(LocalDateTime.now());
contextRepo.save(context);
StateHandler handler = stateHandlers.get(context.getCurrentState());
return "好的,已返回上一步。" + handler.getEntryPrompt(context);
}
/**
* 获取当前步骤进度(用于前端显示进度条)
*/
public ProgressInfo getProgress(String sessionId) {
LoanApplicationContext context = loadOrCreateContext(sessionId);
List<LoanApplicationState> steps = List.of(
LoanApplicationState.COLLECTING_NAME,
LoanApplicationState.COLLECTING_INCOME,
LoanApplicationState.COLLECTING_ASSETS,
LoanApplicationState.REVIEW,
LoanApplicationState.SUBMIT_PENDING
);
int currentStepIndex = steps.indexOf(context.getCurrentState());
return new ProgressInfo(
Math.max(0, currentStepIndex + 1),
steps.size(),
context.getCurrentState().name(),
context.isComplete()
);
}
private LoanApplicationContext loadOrCreateContext(String sessionId) {
return contextRepo.findBySessionId(sessionId)
.orElseGet(() -> {
LoanApplicationContext ctx = LoanApplicationContext.builder()
.sessionId(sessionId)
.currentState(LoanApplicationState.GREETING)
.stateEnteredAt(LocalDateTime.now())
.build();
return contextRepo.save(ctx);
});
}
private void applyExtractedData(LoanApplicationContext context, Map<String, Object> data) {
data.forEach((key, value) -> {
switch (key) {
case "applicantName" -> context.setApplicantName(value.toString());
case "monthlyIncome" -> context.setMonthlyIncome(value.toString());
case "assetDescription" -> context.setAssetDescription(value.toString());
}
});
}
private void clearCurrentStepData(LoanApplicationContext context) {
switch (context.getCurrentState()) {
case COLLECTING_NAME -> context.setApplicantName(null);
case COLLECTING_INCOME -> context.setMonthlyIncome(null);
case COLLECTING_ASSETS -> context.setAssetDescription(null);
}
}
record ProgressInfo(int currentStep, int totalSteps,
String currentStateName, boolean isComplete) {}
}实践建议
状态机不是万能的,要识别适用边界
状态机最适合有明确顺序步骤的任务型对话(表单填写、预约、申请)。对于自由探索型对话(知识问答、技术咨询),强加状态机会限制灵活性、增加复杂度,得不偿失。判断标准:如果你能画出清晰的流程图,就适合状态机;如果流程开放、路径不固定,就用普通多轮对话。
每个状态都要处理"题外问题"
用户在填表过程中突然问一个无关问题,这是必须处理的场景(不是边缘case)。设计时,每个状态都应该能识别"OFF_TOPIC"意图,给出简短回答后把用户引导回当前步骤。如果把题外问题分发到通用QA系统处理,要注意上下文切换后的重新引导逻辑。
状态持久化是容错的基础
用户关闭页面再打开、网络断了重连,对话应该能从中断的地方继续。这要求上下文必须持久化到数据库(Redis适合短期、PostgreSQL适合长期)。每次状态转移都要保存。不能只放在内存里。我见过一个系统因为上下文只在内存,Pod重启一次,所有进行中的对话全部丢失,用户需要从头开始填表,投诉量激增。
