第2084篇:对话系统设计——意图识别与槽位填充的工程实践
2026/4/30大约 11 分钟
第2084篇:对话系统设计——意图识别与槽位填充的工程实践
适读人群:正在构建智能客服或对话系统的工程师 | 阅读时长:约20分钟 | 核心价值:掌握基于LLM的意图识别和槽位填充实现,设计多轮对话的状态管理系统
"帮我查一下北京到上海明天的高铁票"——这句话背后包含了多个信息:意图(查票)、出发地(北京)、目的地(上海)、日期(明天)、交通方式(高铁)。
传统对话系统需要两套独立的模型:一个分类器做意图识别,一个序列标注模型做命名实体提取(槽位填充)。LLM出现之后,这两件事可以合并成一个步骤——但合并的方式有很多,怎么做才能既准确又高效,是这篇文章要讲的。
对话系统的核心结构
意图识别:分层结构设计
/**
* 意图定义:支持层次结构
*
* 大意图 → 子意图
* ORDER → ORDER_QUERY(查询订单)
* → ORDER_CANCEL(取消订单)
* → ORDER_REFUND(申请退款)
*/
@Data
public class IntentDefinition {
private String intentId; // 唯一标识
private String intentName; // 显示名称
private String description; // 描述(给LLM看的)
private String parentIntent; // 父意图ID(null=顶级)
private List<String> examples; // 用户说话的示例
private List<SlotDefinition> requiredSlots; // 必填槽位
private List<SlotDefinition> optionalSlots; // 可选槽位
/**
* 内置意图库(客服场景)
*/
public static List<IntentDefinition> customerServiceIntents() {
return List.of(
IntentDefinition.builder()
.intentId("ORDER_QUERY")
.intentName("查询订单")
.description("用户想查询订单状态、物流信息等")
.examples(List.of(
"我的订单到哪里了",
"帮我查一下快递",
"订单什么时候发货",
"123456789这个单子怎么样了"
))
.requiredSlots(List.of(
SlotDefinition.of("orderId", "订单号", SlotType.ORDER_ID, false)
))
.build(),
IntentDefinition.builder()
.intentId("ORDER_CANCEL")
.intentName("取消订单")
.description("用户想取消尚未发货的订单")
.examples(List.of("不想要了", "取消订单", "能退吗"))
.requiredSlots(List.of(
SlotDefinition.of("orderId", "订单号", SlotType.ORDER_ID, false),
SlotDefinition.of("cancelReason", "取消原因", SlotType.TEXT, true)
))
.build(),
IntentDefinition.builder()
.intentId("PRODUCT_INQUIRY")
.intentName("产品咨询")
.description("询问产品信息、规格、价格等")
.examples(List.of("这个多少钱", "有没有红色的", "质量怎么样"))
.optionalSlots(List.of(
SlotDefinition.of("productName", "商品名称", SlotType.TEXT, true),
SlotDefinition.of("attribute", "询问属性", SlotType.TEXT, true)
))
.build()
);
}
}基于LLM的意图识别
/**
* LLM驱动的意图识别
* 对比方案:规则匹配(快但脆)→ 分类模型(中等)→ LLM(慢但强)
*
* 实战策略:先用规则/快速模型过滤,复杂情况才调用LLM
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class IntentClassifier {
private final ChatLanguageModel llm;
private final List<IntentDefinition> intentDefinitions;
// 快速规则匹配(避免每次都调LLM)
private final Map<String, String> keywordToIntent = Map.of(
"查订单", "ORDER_QUERY",
"查快递", "ORDER_QUERY",
"取消", "ORDER_CANCEL",
"退款", "REFUND_REQUEST",
"退货", "RETURN_REQUEST"
);
/**
* 两阶段意图识别
* Phase 1: 规则快速匹配(~1ms)
* Phase 2: LLM精确分类(~500ms)
*/
public IntentResult classify(String userMessage, List<ChatMessage> history) {
// Phase 1: 关键词快速匹配
IntentResult quickResult = quickMatch(userMessage);
if (quickResult != null && quickResult.confidence() >= 0.9) {
log.debug("关键词快速匹配成功: intent={}", quickResult.intentId());
return quickResult;
}
// Phase 2: LLM精确分类
return llmClassify(userMessage, history);
}
private IntentResult quickMatch(String message) {
for (Map.Entry<String, String> entry : keywordToIntent.entrySet()) {
if (message.contains(entry.getKey())) {
return new IntentResult(entry.getValue(), 0.9, Map.of());
}
}
return null;
}
private IntentResult llmClassify(String message, List<ChatMessage> history) {
// 构建意图列表描述
String intentList = intentDefinitions.stream()
.map(i -> String.format("- %s: %s(示例:%s)",
i.getIntentId(),
i.getDescription(),
String.join("、", i.getExamples().subList(0,
Math.min(2, i.getExamples().size())))))
.collect(Collectors.joining("\n"));
// 最近的对话上下文(最多3轮)
String contextStr = history.stream()
.skip(Math.max(0, history.size() - 6))
.map(msg -> (msg instanceof UserMessage ? "用户" : "客服") +
": " + msg.singleText())
.collect(Collectors.joining("\n"));
String prompt = String.format("""
判断用户意图,从以下意图列表中选择最匹配的一个。
意图列表:
%s
- UNKNOWN: 无法判断意图或不在列表中
%s
当前用户消息:%s
请以JSON格式输出,例如:
{"intentId": "ORDER_QUERY", "confidence": 0.9, "reason": "用户询问订单状态"}
只输出JSON,不要其他内容:
""",
intentList,
contextStr.isEmpty() ? "" : "对话上下文:\n" + contextStr + "\n",
message
);
try {
String response = llm.generate(prompt).trim();
String json = extractJson(response);
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> result = mapper.readValue(json, new TypeReference<>() {});
String intentId = (String) result.get("intentId");
double confidence = ((Number) result.getOrDefault("confidence", 0.7)).doubleValue();
log.debug("LLM意图分类: input='{}', intent={}, confidence={}",
message, intentId, confidence);
return new IntentResult(intentId, confidence, result);
} catch (Exception e) {
log.error("意图分类解析失败: {}", e.getMessage());
return new IntentResult("UNKNOWN", 0.0, Map.of());
}
}
private String extractJson(String text) {
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
return start >= 0 && end > start ? text.substring(start, end + 1) : "{}";
}
public record IntentResult(
String intentId,
double confidence,
Map<String, Object> rawResponse
) {
public boolean isUnknown() {
return "UNKNOWN".equals(intentId) || confidence < 0.5;
}
}
}槽位填充:提取结构化信息
/**
* 槽位定义
*/
@Data
public class SlotDefinition {
private String slotName;
private String displayName;
private SlotType type;
private boolean optional;
private String promptTemplate; // 当槽位缺失时,问用户的话
private List<String> validValues; // 枚举类型的合法值
public static SlotDefinition of(String name, String displayName,
SlotType type, boolean optional) {
SlotDefinition def = new SlotDefinition();
def.setSlotName(name);
def.setDisplayName(displayName);
def.setType(type);
def.setOptional(optional);
def.setPromptTemplate("请提供您的" + displayName);
return def;
}
public enum SlotType {
ORDER_ID, // 订单号(特定格式)
PHONE_NUMBER, // 手机号
DATE, // 日期(今天/明天/2024-01-01等)
TIME_RANGE, // 时间段
ENUM, // 枚举值
TEXT // 自由文本
}
}
/**
* 槽位填充器
* 从用户输入中提取结构化信息
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class SlotFiller {
private final ChatLanguageModel llm;
/**
* 从用户消息中提取槽位值
*
* @param message 当前用户消息
* @param intent 已识别的意图
* @param history 对话历史
* @param existingSlots 已有的槽位值(多轮对话中积累的)
* @return 提取到的槽位值(增量,不包含未提取到的)
*/
public Map<String, Object> extractSlots(
String message,
IntentDefinition intent,
List<ChatMessage> history,
Map<String, Object> existingSlots) {
// 只提取还没有值的槽位
List<SlotDefinition> missingSlots = intent.getAllSlots().stream()
.filter(slot -> !existingSlots.containsKey(slot.getSlotName()))
.toList();
if (missingSlots.isEmpty()) {
return Map.of();
}
// 构建槽位提取prompt
String slotsDesc = missingSlots.stream()
.map(s -> String.format("- %s (%s): %s类型%s",
s.getSlotName(),
s.getDisplayName(),
s.getType().name(),
s.getValidValues() != null ?
",合法值:" + String.join("/", s.getValidValues()) : ""))
.collect(Collectors.joining("\n"));
// 包含对话历史,支持跨轮填充
String contextStr = buildContextString(history, message);
String prompt = String.format("""
从以下对话中提取指定信息,没有明确提及的输出null。
需要提取的信息:
%s
对话:
%s
输出JSON(只包含能确定的字段,不确定的不要输出):
""",
slotsDesc, contextStr
);
try {
String response = llm.generate(prompt).trim();
String json = extractJson(response);
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> extracted = mapper.readValue(json, new TypeReference<>() {});
// 过滤掉null值
Map<String, Object> nonNullSlots = extracted.entrySet().stream()
.filter(e -> e.getValue() != null)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
// 验证提取的槽位值
Map<String, Object> validated = validateSlots(nonNullSlots, missingSlots);
log.debug("槽位提取: intent={}, extracted={}", intent.getIntentId(), validated);
return validated;
} catch (Exception e) {
log.error("槽位提取失败: {}", e.getMessage());
return Map.of();
}
}
/**
* 验证槽位值的合法性
*/
private Map<String, Object> validateSlots(
Map<String, Object> extracted,
List<SlotDefinition> slotDefs) {
Map<String, SlotDefinition> defMap = slotDefs.stream()
.collect(Collectors.toMap(SlotDefinition::getSlotName, s -> s));
Map<String, Object> validated = new HashMap<>();
for (Map.Entry<String, Object> entry : extracted.entrySet()) {
String slotName = entry.getKey();
Object value = entry.getValue();
SlotDefinition def = defMap.get(slotName);
if (def == null) continue; // 未知槽位
// 类型验证
if (isValidSlotValue(value, def)) {
validated.put(slotName, value);
} else {
log.debug("槽位值验证失败: slotName={}, value={}, type={}",
slotName, value, def.getType());
}
}
return validated;
}
private boolean isValidSlotValue(Object value, SlotDefinition def) {
if (value == null) return false;
String str = value.toString();
return switch (def.getType()) {
case ORDER_ID -> str.matches("\\d{8,20}"); // 8-20位数字
case PHONE_NUMBER -> str.matches("1[3-9]\\d{9}"); // 中国手机号
case DATE -> str.matches("\\d{4}-\\d{2}-\\d{2}") ||
List.of("今天", "明天", "后天").contains(str);
case ENUM -> def.getValidValues() == null ||
def.getValidValues().contains(str);
default -> !str.isEmpty();
};
}
private String buildContextString(List<ChatMessage> history, String currentMessage) {
StringBuilder sb = new StringBuilder();
// 最近4轮对话
List<ChatMessage> recentHistory = history.subList(
Math.max(0, history.size() - 8), history.size());
for (ChatMessage msg : recentHistory) {
String role = msg instanceof UserMessage ? "用户" : "客服";
sb.append(role).append(": ").append(msg.singleText()).append("\n");
}
sb.append("用户: ").append(currentMessage);
return sb.toString();
}
private String extractJson(String text) {
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
return start >= 0 && end > start ? text.substring(start, end + 1) : "{}";
}
}对话状态管理
/**
* 对话状态跟踪
* 维护多轮对话中的意图和槽位积累
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class DialogueStateTracker {
private final IntentClassifier intentClassifier;
private final SlotFiller slotFiller;
private final IntentRepository intentRepository;
// 对话状态存储(生产环境用Redis)
private final Map<String, DialogueState> stateStore = new ConcurrentHashMap<>();
/**
* 处理用户消息,更新对话状态
* 这是对话系统的核心方法
*/
public DialogueTurn process(String sessionId, String userMessage) {
// 1. 获取或初始化对话状态
DialogueState state = stateStore.computeIfAbsent(sessionId,
id -> new DialogueState(id));
// 2. 识别意图
IntentClassifier.IntentResult intentResult =
intentClassifier.classify(userMessage, state.getHistory());
// 3. 判断意图切换
boolean intentSwitched = !intentResult.intentId().equals(state.getCurrentIntentId())
&& !intentResult.isUnknown();
if (intentSwitched) {
log.info("意图切换: sessionId={}, old={}, new={}",
sessionId, state.getCurrentIntentId(), intentResult.intentId());
// 意图切换时,清空旧的槽位(通常需要重新填充)
state.setCurrentIntentId(intentResult.intentId());
state.getFilledSlots().clear();
} else if (!intentResult.isUnknown() && state.getCurrentIntentId() == null) {
state.setCurrentIntentId(intentResult.intentId());
}
// 4. 槽位填充
if (state.getCurrentIntentId() != null) {
IntentDefinition intent = intentRepository.findById(state.getCurrentIntentId());
if (intent != null) {
// 从当前消息+历史中提取槽位
Map<String, Object> newSlots = slotFiller.extractSlots(
userMessage, intent, state.getHistory(), state.getFilledSlots());
// 合并到已有槽位
state.getFilledSlots().putAll(newSlots);
}
}
// 5. 判断是否所有必填槽位都已填充
String nextAction = determineNextAction(state);
// 6. 记录历史
state.getHistory().add(UserMessage.from(userMessage));
return new DialogueTurn(
sessionId,
intentResult.intentId(),
intentResult.confidence(),
new HashMap<>(state.getFilledSlots()),
nextAction,
getMissingRequiredSlots(state),
intentSwitched
);
}
/**
* 判断下一步动作
* - EXECUTE: 所有必填槽位已满,可以执行意图
* - ASK_SLOT: 还有必填槽位未填,需要向用户询问
* - CLARIFY: 意图不确定,需要澄清
* - UNKNOWN: 无法处理
*/
private String determineNextAction(DialogueState state) {
if (state.getCurrentIntentId() == null || "UNKNOWN".equals(state.getCurrentIntentId())) {
return "UNKNOWN";
}
List<String> missingSlots = getMissingRequiredSlots(state);
if (missingSlots.isEmpty()) {
return "EXECUTE";
}
return "ASK_SLOT";
}
private List<String> getMissingRequiredSlots(DialogueState state) {
if (state.getCurrentIntentId() == null) return List.of();
IntentDefinition intent = intentRepository.findById(state.getCurrentIntentId());
if (intent == null) return List.of();
return intent.getRequiredSlots().stream()
.filter(slot -> !slot.isOptional())
.filter(slot -> !state.getFilledSlots().containsKey(slot.getSlotName()))
.map(SlotDefinition::getSlotName)
.toList();
}
/**
* 对话状态
*/
@Data
public static class DialogueState {
private String sessionId;
private String currentIntentId;
private Map<String, Object> filledSlots = new HashMap<>();
private List<ChatMessage> history = new ArrayList<>();
private LocalDateTime lastUpdateTime = LocalDateTime.now();
public DialogueState(String sessionId) {
this.sessionId = sessionId;
}
}
public record DialogueTurn(
String sessionId,
String intentId,
double intentConfidence,
Map<String, Object> filledSlots,
String nextAction,
List<String> missingSlots,
boolean intentSwitched
) {}
}意图执行层和响应生成
/**
* 意图处理器注册表
* 每种意图对应一个处理器
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class IntentHandlerRegistry {
private final Map<String, IntentHandler> handlers;
private final ChatLanguageModel llm;
private final DialogueStateTracker stateTracker;
/**
* 完整的对话轮次处理
*/
public String handleTurn(String sessionId, String userMessage) {
// 1. 对话状态追踪
DialogueStateTracker.DialogueTurn turn =
stateTracker.process(sessionId, userMessage);
// 2. 根据下一步动作决定响应
return switch (turn.nextAction()) {
case "EXECUTE" -> executeIntent(turn);
case "ASK_SLOT" -> askForMissingSlot(turn);
case "CLARIFY" -> askForClarification(userMessage);
default -> "抱歉,我没有理解您的意思,能再说一遍吗?";
};
}
private String executeIntent(DialogueStateTracker.DialogueTurn turn) {
IntentHandler handler = handlers.get(turn.intentId());
if (handler == null) {
log.warn("没有找到意图处理器: {}", turn.intentId());
return "抱歉,这个功能暂时无法处理,请联系人工客服。";
}
try {
IntentHandler.ExecutionResult result =
handler.execute(turn.filledSlots());
// 用LLM把结构化结果转成自然语言
return generateNaturalResponse(turn.intentId(), result);
} catch (IntentHandler.BusinessException e) {
return "抱歉," + e.getUserMessage();
} catch (Exception e) {
log.error("意图执行失败: intent={}", turn.intentId(), e);
return "系统繁忙,请稍后再试,或联系人工客服。";
}
}
private String askForMissingSlot(DialogueStateTracker.DialogueTurn turn) {
if (turn.missingSlots().isEmpty()) return "请问还有什么可以帮您?";
// 取第一个缺失的必填槽位来询问
String missingSlot = turn.missingSlots().get(0);
// 从意图定义中找到这个槽位的询问话术
return getSlotPrompt(turn.intentId(), missingSlot);
}
private String askForClarification(String userMessage) {
String prompt = String.format("""
用户说:"%s"
这句话的意图不清楚,用一句话询问用户具体想做什么:
""", userMessage);
return llm.generate(prompt).trim();
}
private String generateNaturalResponse(String intentId,
IntentHandler.ExecutionResult result) {
if (!result.isSuccess()) {
return result.errorMessage();
}
// 把结构化的执行结果转成自然语言
String prompt = String.format("""
请将以下查询结果转成自然、友好的回复(50字以内):
意图:%s
结果:%s
回复:
""", intentId, result.data().toString());
return llm.generate(prompt).trim();
}
private String getSlotPrompt(String intentId, String slotName) {
// 从意图仓库获取槽位的询问话术
// 这里简化处理
Map<String, String> slotPrompts = Map.of(
"orderId", "请提供您的订单号(在购买记录或短信通知中可以找到)",
"phoneNumber", "请提供您的手机号码",
"cancelReason", "请问您取消订单的原因是什么?(更换商品/不想要了/其他)"
);
return slotPrompts.getOrDefault(slotName, "请提供" + slotName + "的信息");
}
}
/**
* 意图处理器接口
*/
public interface IntentHandler {
ExecutionResult execute(Map<String, Object> slots);
record ExecutionResult(boolean isSuccess, Object data, String errorMessage) {
public static ExecutionResult success(Object data) {
return new ExecutionResult(true, data, null);
}
public static ExecutionResult failure(String message) {
return new ExecutionResult(false, null, message);
}
}
class BusinessException extends RuntimeException {
private final String userMessage;
public BusinessException(String userMessage) {
super(userMessage);
this.userMessage = userMessage;
}
public String getUserMessage() { return userMessage; }
}
}订单查询意图处理器示例
/**
* 订单查询意图处理器实现
*/
@Service("ORDER_QUERY")
@RequiredArgsConstructor
@Slf4j
public class OrderQueryHandler implements IntentHandler {
private final OrderService orderService;
@Override
public ExecutionResult execute(Map<String, Object> slots) {
String orderId = (String) slots.get("orderId");
if (orderId == null || orderId.isEmpty()) {
throw new BusinessException("未提供订单号");
}
try {
OrderInfo order = orderService.findByOrderId(orderId);
if (order == null) {
throw new BusinessException("未找到订单号 " + orderId + " 的订单,请确认订单号是否正确");
}
return ExecutionResult.success(Map.of(
"orderId", order.getOrderId(),
"status", order.getStatus().getDisplayName(),
"logisticsInfo", order.getLogisticsInfo(),
"estimatedDelivery", order.getEstimatedDelivery()
));
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("查询订单失败: orderId={}", orderId, e);
throw new BusinessException("查询失败,请稍后重试");
}
}
}实际效果展示
一个完整的多轮对话示例:
用户: 我想查一下我的快递
系统: → 意图: ORDER_QUERY,缺失槽位: orderId
系统: 请提供您的订单号(在购买记录或短信通知中可以找到)
用户: 是123456789012
系统: → 槽位填充: orderId=123456789012,所有必填槽位已满,执行意图
系统: 您好!您的订单123456789012目前处于"在途中"状态,预计明天下午送达。快递公司是顺丰,单号SF1234567890。
用户: 那我想取消另一个单
系统: → 意图切换: ORDER_QUERY → ORDER_CANCEL,清空旧槽位
系统: 请提供您想取消的订单号
用户: 9876543210,因为买重了
系统: → 槽位填充: orderId=9876543210, cancelReason=买重了
系统: 好的,已成功申请取消订单9876543210,退款将在3-5个工作日退回原支付方式。意图切换和多轮槽位填充是对话系统中最难处理的两类场景。设计好状态机,把"意图识别"和"槽位填充"分开处理,会比把所有逻辑塞进一个大prompt要清晰得多,也更容易维护和测试。
