访谈式 AI 经验萃取引擎设计:多轮对话引导与结构化知识抽取的工程实现
一、问题拆解:为什么"直接让 AI 采访"行不通
拿到这个需求,最直觉的方案是给大模型写一个 Prompt:"你是一位经验丰富的课程开发顾问,请采访这位专家并整理成课程大纲。"然后让它自由对话。实测下来,这条路会撞上四个问题。
问题一:AI 提问漫无目的。 自由对话模式下,LLM 的提问随机游走——先问了客户异议处理,又跳到团队管理,再回到报价策略。一场 20 轮的对话下来,有的维度被问了三遍,有的关键维度(比如"丢单后怎么复盘")从未被触及。没有结构约束的访谈,产出的是"聊天记录"而不是"知识覆盖"。
问题二:专家的回答是碎的。 真实专家的表达方式是这样的:“报价这块吧,其实看客户。大客户你别急着报,先吊着;小客户反而要快,他们比价心理重。哦对了,报价前一定要摸清楚预算,不然白忙。”——三句话里塞了两个维度的知识(时机策略 + 前置动作),还带条件分支(大客户 vs 小客户)。如果只在对话结束"最后一轮"才做整理,这些结构信息早就混成一锅粥了。
问题三:LLM 会"帮忙补全"。 专家说"报价策略大概就是先高后低",LLM 在整理大纲时会自动脑补出一套完整的报价方法论——听起来漂亮,但不是专家说的。经验萃取的第一原则是"忠实于专家",AI 编造的内容混进去,专家看到成品会觉得"这不是我说的",信任崩塌,后面就再也不配合了。
问题四:专家的时间是碎片化的。 资深员工不可能坐下来接受两小时连续访谈,更常见的场景是"我下午有 20 分钟"。访谈引擎必须支持随时开始、随时暂停、随时提前结束——并且在提前结束时,产出一份"诚实标注了缺口"的大纲,而不是硬凑一份完整的。
这四个问题指向同一个架构答案:访谈不能是自由对话,必须是一个由"维度树"驱动的状态机流程,每一轮对话结束就即时抽取结构化知识点,大纲在访谈过程中渐进式生长,而不是最后一把生成。
二、整体架构:状态机 + 维度树 + 增量抽取
┌──────────────────────────────────────────────────────────────┐
│ 访谈会话层 │
│ 访谈配置(主题/受众/目标/轮次) 访谈进度 会话管理 │
└──────────────────────────┬───────────────────────────────────┘
│
┌──────────────────────────▼───────────────────────────────────┐
│ 访谈引擎 (Interview Engine) │
│ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────┐ │
│ │ ① 访谈状态机 │───▶│ ② 提问生成器 │───▶│ ③ 增量知识 │ │
│ │ (InterviewFSM) │ │ (Question │ │ 抽取器 │ │
│ │ │ │ Generator) │ │ (Incremental│ │
│ │ OPENING │ │ │ │ Extractor) │ │
│ │ TOPIC_CONFIRM │ │ 维度树驱动 │ │ │ │
│ │ DIMENSION_DIVE │◀── │ 覆盖度感知 │ │ 每轮即时 │ │
│ │ GAP_PROBE │ │ 上下文注入 │ │ 抽取要点 │ │
│ │ WRAP_UP │ └────────────────┘ │ 挂接溯源 │ │
│ └────────────────┘ └──────┬─────┘ │
│ │ │
│ ┌─────────────────────┐ ┌─────────────────────▼──────┐ │
│ │ ④ 维度树管理器 │ │ ⑤ 大纲渐进生成器 │ │
│ │ (DimensionTree │◀── │ (OutlineGrower) │ │
│ │ Manager) │ │ 缺口标记 / 章节装配 │ │
│ │ 课程类型模板 + │ │ │ │
│ │ 动态维度扩展 │ └────────────────────────────┘ │
│ └─────────────────────┘ │
└──────────────────────────┬───────────────────────────────────┘
│
┌──────────────────────────▼───────────────────────────────────┐
│ 内容生产层(下游) │
│ 大纲 → PPT 生成 → PDF/Word 导出 → AI 视频 → 课程/知识库发布 │
└──────────────────────────────────────────────────────────────┘
五个核心组件的分工:
访谈状态机控制整个访谈的宏观流程:开场(确认主题、受众、目标)→ 分维度深挖(按维度树逐个展开)→ 查漏追问(覆盖度不足的维度补充)→ 收尾(专家确认大纲)。状态转换由"维度覆盖度"驱动,而不是固定轮次。
维度树是访谈的骨架。开访谈前,根据"课程主题 × 受众 × 课程类型"生成一棵维度树(比如销售话术课程的维度树包含"客户开拓/需求挖掘/异议处理/报价成交/复盘"五个一级维度,每个维度下再分二级维度)。访谈按维度树推进,每个维度结束时计算覆盖率,覆盖不足触发追问。
提问生成器负责把"当前要问的维度"翻译成一个自然的、带上下文的提问。它感知三个东西:已问过什么(避免重复)、专家上一轮说了什么(顺着追问)、覆盖度缺口在哪(往缺口引导)。
增量知识抽取器在每轮对话结束后立即运行——从专家的回答中抽取结构化知识点(要点、条件分支、案例、数字),每个知识点挂接"溯源引用"(出自第几轮对话的原文)。这是防幻觉和"随时结束"的基础。
大纲渐进生成器维护一棵持续生长的大纲树:每轮抽取的知识点即时归入对应章节,访谈结束时大纲已经就绪。专家提前结束时,未覆盖的维度在大纲中标记为"待补充",而不是让 AI 编造。
三、访谈状态机:流程的宏观控制
3.1 状态机定义
/**
* 访谈状态机
*
* 状态流转:
* OPENING → TOPIC_CONFIRM → DIMENSION_DIVE ⇄ GAP_PROBE → WRAP_UP → DONE
*
* 特殊流转:
* - 任意状态 → WRAP_UP:专家主动结束访谈("随时结束"的产品要求)
* - DIMENSION_DIVE 内部按维度树推进,每完成一个维度计算覆盖率
* - GAP_PROBE 处理覆盖度不足的维度(追问)
*/
public enum InterviewState {
/** 开场:介绍访谈流程,确认基本设置 */
OPENING,
/** 主题确认:与专家对齐课程主题、受众、目标 */
TOPIC_CONFIRM,
/** 维度深挖:按维度树逐个维度展开访谈 */
DIMENSION_DIVE,
/** 查漏追问:对覆盖度不足的维度补充提问 */
GAP_PROBE,
/** 收尾:展示当前大纲,请专家确认或补充 */
WRAP_UP,
/** 完成 */
DONE
}
/**
* 访谈会话:状态机的载体
*/
@Data
public class InterviewSession {
private String sessionId;
private Long tenantId;
private Long expertUserId; // 受访专家
/** 访谈配置 */
private InterviewConfig config;
/** 当前状态 */
private InterviewState state;
/** 维度树(访谈骨架) */
private DimensionTree dimensionTree;
/** 对话历史(每轮 Q&A 独立存储) */
private List<DialogTurn> turns = new ArrayList<>();
/** 已抽取的知识点(增量累积) */
private List<ExtractedKnowledge> knowledgePoints = new ArrayList<>();
/** 当前正在访谈的维度 */
private String currentDimensionId;
/** 访谈开始/暂停时间(支持碎片化访谈) */
private Long startedAt;
private Long pausedAt;
/** 最大轮次(专家可配置) */
private Integer maxTurns;
/** 是否被专家提前结束 */
private Boolean endedEarly;
}
3.2 状态流转核心逻辑
/**
* 访谈引擎:状态机的驱动逻辑
*/
@Service
public class InterviewEngine {
@Autowired
private QuestionGenerator questionGenerator;
@Autowired
private IncrementalExtractor incrementalExtractor;
@Autowired
private OutlineGrower outlineGrower;
@Autowired
private DimensionTreeManager dimensionTreeManager;
/**
* 处理专家的一轮回答,返回 AI 的下一个提问
*/
public InterviewTurnResult handleExpertReply(InterviewSession session, String expertReply) {
// 1. 记录本轮对话
DialogTurn turn = new DialogTurn();
turn.setTurnIndex(session.getTurns().size() + 1);
turn.setQuestion(session.getLastQuestion());
turn.setAnswer(expertReply);
turn.setTimestamp(System.currentTimeMillis());
session.getTurns().add(turn);
// 2. 增量知识抽取(每轮立即执行,不等访谈结束)
List<ExtractedKnowledge> newKnowledge = incrementalExtractor.extract(turn, session);
session.getKnowledgePoints().addAll(newKnowledge);
// 3. 大纲渐进更新(新知识点即时归入大纲)
outlineGrower.ingest(session, newKnowledge);
// 4. 状态机流转 + 生成下一个提问
String nextQuestion;
InterviewState nextState = computeNextState(session);
switch (nextState) {
case DIMENSION_DIVE:
// 继续当前维度深挖,或切换到下一个维度
nextQuestion = questionGenerator.generateDiveQuestion(session);
break;
case GAP_PROBE:
// 覆盖度不足,生成追问
nextQuestion = questionGenerator.generateGapProbeQuestion(session);
break;
case WRAP_UP:
// 进入收尾:展示大纲摘要请专家确认
nextQuestion = questionGenerator.generateWrapUpQuestion(session);
break;
default:
nextQuestion = null;
}
session.setState(nextState);
session.setLastQuestion(nextQuestion);
return new InterviewTurnResult(nextQuestion, nextState, session);
}
/**
* 计算下一个状态
*
* 决策优先级:
* 1. 达到最大轮次 或 专家主动结束 → WRAP_UP
* 2. 当前维度覆盖率达标 → 下一个未完成维度;全部完成 → 查漏阶段
* 3. 当前维度覆盖不足且已问了 3+ 轮 → 切换维度(避免死磕一个维度)
* 4. 默认:继续当前维度深挖
*/
private InterviewState computeNextState(InterviewSession session) {
// 轮次耗尽或提前结束
if (session.getTurns().size() >= session.getMaxTurns()
|| Boolean.TRUE.equals(session.getEndedEarly())) {
return InterviewState.WRAP_UP;
}
DimensionTree tree = session.getDimensionTree();
// 当前维度覆盖率达标 → 推进
Dimension current = tree.getDimension(session.getCurrentDimensionId());
if (current != null && current.getCoverageScore() >= 0.7) {
current.setStatus(DimensionStatus.COVERED);
// 找下一个未完成的维度
Dimension next = tree.nextUncoveredDimension();
if (next != null) {
session.setCurrentDimensionId(next.getId());
return InterviewState.DIMENSION_DIVE;
}
// 所有维度都过了一遍 → 进入查漏(低覆盖维度补问)
if (tree.hasLowCoverageDimensions()) {
session.setCurrentDimensionId(
tree.topLowCoverageDimension().getId()
);
return InterviewState.GAP_PROBE;
}
// 全部覆盖 → 收尾
return InterviewState.WRAP_UP;
}
// 当前维度覆盖不足
// 防死磕:单维度追问超过 3 轮仍低覆盖,标记为"维度受限"并跳过
if (current != null && current.getTurnsSpent() >= 3
&& current.getCoverageScore() < 0.4) {
current.setStatus(DimensionStatus.LIMITED); // 专家在这个维度经验有限
Dimension next = tree.nextUncoveredDimension();
if (next != null) {
session.setCurrentDimensionId(next.getId());
return InterviewState.DIMENSION_DIVE;
}
return InterviewState.WRAP_UP;
}
// 继续当前维度
return InterviewState.DIMENSION_DIVE;
}
}
"维度受限"状态是个重要的产品细节:不是每个专家在每个维度都有干货。追问一个没经验的维度,专家只能硬编——产出质量反而下降。状态机允许标记"维度受限",大纲中该维度显示"本课程暂不覆盖",诚实优于完整。
四、维度树:访谈的骨架
4.1 维度树的结构
/**
* 访谈维度树
*
* 生成方式:课程类型模板(预置)+ LLM 动态扩展(按主题定制)
*/
@Data
public class DimensionTree {
private String treeId;
private List<Dimension> rootDimensions = new ArrayList<>();
/** 统计:已覆盖 / 受限 / 未访谈 的维度数 */
public Dimension nextUncoveredDimension() {
return rootDimensions.stream()
.flatMap(this::flatten)
.filter(d -> d.getStatus() == DimensionStatus.PENDING)
.findFirst().orElse(null);
}
public boolean hasLowCoverageDimensions() {
return rootDimensions.stream()
.flatMap(this::flatten)
.anyMatch(d -> d.getStatus() == DimensionStatus.PARTIAL
|| d.getCoverageScore() < 0.7);
}
}
@Data
public class Dimension {
private String id;
private String name; // 如"报价策略"
private String description; // 访谈提示语(给提问生成器的指令)
private String parentId;
private int depth; // 一级 / 二级维度
private DimensionStatus status; // PENDING / PARTIAL / COVERED / LIMITED
private double coverageScore; // 0-1,由知识抽取结果计算
private int turnsSpent; // 本维度已花掉的轮次
/** 本维度期望覆盖的要点清单(模板定义,用于覆盖率计算) */
private List<String> expectedPoints = new ArrayList<>();
}
public enum DimensionStatus {
PENDING, // 未访谈
PARTIAL, // 部分覆盖
COVERED, // 覆盖达标
LIMITED // 维度受限(专家经验有限,主动跳过)
}
4.2 维度树生成:模板 + LLM 定制
维度树的生成采用"两层":先从课程类型模板库取出基础维度(保证覆盖度下限),再让 LLM 根据具体主题做定制扩展(保证针对性)。
/**
* 维度树管理器
*
* 两层生成策略:
* Layer 1: 课程类型模板(预置 5 类:销售话术/售后流程/技术排查/项目复盘/带教手册)
* Layer 2: LLM 按主题动态扩展(增补模板没覆盖的领域特有维度)
*/
@Service
public class DimensionTreeManager {
@Autowired
private LlmClient llmClient;
/**
* 生成维度树
*/
public DimensionTree generateTree(InterviewConfig config) {
// Layer 1: 加载课程类型模板
InterviewTemplate template = templateRepo
.getByCourseType(config.getCourseType());
DimensionTree tree = template.getBaseTree();
// Layer 2: LLM 按主题定制
String customizePrompt = buildCustomizePrompt(config, tree);
LlmResponse resp = llmClient.chat(customizePrompt, Map.of(
"temperature", 0.3,
"response_format", "json"
));
List<Dimension> extraDimensions = parseDimensions(resp.getContent());
// 合并:LLM 新增的维度追加,与模板重名的去重
for (Dimension d : extraDimensions) {
if (!tree.containsDimension(d.getName())) {
tree.getRootDimensions().add(d);
}
}
return tree;
}
private String buildCustomizePrompt(InterviewConfig config, DimensionTree baseTree) {
return String.format("""
你是一位企业培训课程设计专家。我要访谈一位资深员工,萃取其经验形成课程。
## 课程信息
- 课程主题:%s
- 受众群体:%s
- 课程目标:%s
- 课程类型:%s
## 已有的访谈维度(模板预置)
%s
## 任务
请基于课程主题,补充 2-4 个模板未覆盖的、该主题特有的访谈维度。
要求:
1. 每个维度给出一句话描述(访谈时应该引导专家讲什么)
2. 每个维度列出 3-5 个期望覆盖的要点(用于后续覆盖率计算)
3. 维度要具体可问,避免空泛("沟通技巧"太泛,"价格异议的三步应对"是好维度)
4. 只输出新增维度,不要重复已有维度
输出 JSON 数组格式:
[
{
"name": "维度名",
"description": "一句话访谈引导描述",
"expectedPoints": ["要点1", "要点2"]
}
]
""",
config.getTopic(),
config.getAudience(),
config.getGoal(),
config.getCourseType(),
formatExistingDimensions(baseTree)
);
}
}
五类课程模板的维度差异(这也是模板库的设计核心):
| 课程类型 | 一级维度(示例) | 知识结构特点 |
|---|---|---|
| 销售话术 | 客户开拓 / 需求挖掘 / 异议处理 / 报价成交 / 丢单复盘 | 场景应对型:每个异议场景 → 应对话术 |
| 售后流程 | 接单响应 / 问题诊断 / 处理方案 / 升级路径 / 回访闭环 | 流程步骤型:按流程节点组织知识 |
| 技术排查 | 症状识别 / 分层排查 / 常见根因 / 修复操作 / 预防措施 | 决策树型:症状 → 分支 → 根因 → 处置 |
| 项目复盘 | 项目背景 / 关键决策 / 踩坑记录 / 应对措施 / 经验教训 | 时间线型:按项目阶段 + 教训组织 |
| 带教手册 | 岗位认知 / 核心流程 / 常见错误 / 成长路径 / 检查清单 | 任务清单型:新人按任务逐步上手 |
4.3 覆盖率计算
覆盖率驱动状态机流转,计算逻辑基于"维度期望要点 vs 已抽取知识点"的匹配:
/**
* 覆盖率计算器
*
* 覆盖率 = 已覆盖的期望要点数 / 总期望要点数
*
* "已覆盖"的判定:该维度下的已抽取知识点中,
* 存在与期望要点语义相似度 > 0.7 的知识点(Embedding 匹配)
*/
@Service
public class CoverageCalculator {
@Autowired
private EmbeddingClient embeddingClient;
public double calculate(Dimension dimension, List<ExtractedKnowledge> knowledgePoints) {
List<String> expected = dimension.getExpectedPoints();
if (expected.isEmpty()) return 1.0;
// 该维度下的知识点(按溯源归属过滤)
List<ExtractedKnowledge> dimKnowledge = knowledgePoints.stream()
.filter(k -> dimension.getId().equals(k.getDimensionId()))
.collect(Collectors.toList());
if (dimKnowledge.isEmpty()) return 0.0;
// 期望要点 → Embedding
List<float[]> expectedVecs = expected.stream()
.map(embeddingClient::embed)
.collect(Collectors.toList());
// 知识点 → Embedding
List<float[]> knowledgeVecs = dimKnowledge.stream()
.map(k -> embeddingClient.embed(k.getStatement()))
.collect(Collectors.toList());
// 逐个期望要点找最相似的知识点
int covered = 0;
for (float[] expectedVec : expectedVecs) {
for (float[] knowledgeVec : knowledgeVecs) {
if (cosineSimilarity(expectedVec, knowledgeVec) > 0.7) {
covered++;
break;
}
}
}
return (double) covered / expected.size();
}
}
五、提问生成器:把维度翻译成自然的提问
提问生成器是专家直接面对的部分,体验决定访谈成败。它要感知三类上下文,生成"像一位资深顾问在提问"的问句。
/**
* 提问生成器
*
* 三类提问模式:
* 1. 维度开启提问:切入新维度的第一个开放性问题
* 2. 维度深挖提问:顺着专家上一轮回答中的线索追问
* 3. 查漏追问提问:针对覆盖缺口的定向追问
*/
@Service
public class QuestionGenerator {
@Autowired
private LlmClient llmClient;
/**
* 模式 1:维度开启提问
*/
public String generateDiveQuestion(InterviewSession session) {
Dimension current = session.getDimensionTree()
.getDimension(session.getCurrentDimensionId());
boolean isNewDimension = current.getTurnsSpent() == 0;
if (isNewDimension) {
return buildDimensionOpeningQuestion(session, current);
} else {
return buildDeepDiveQuestion(session, current);
}
}
/**
* 维度开启提问的 Prompt
*/
private String buildDimensionOpeningQuestion(InterviewSession session, Dimension dim) {
// 取最近一轮对话作为衔接上下文
String lastExchange = session.getTurns().isEmpty() ? "" :
session.getTurns().get(session.getTurns().size() - 1).getSummary();
return llmClient.chat(String.format("""
你是一位经验丰富的企业知识访谈顾问,正在访谈一位资深员工萃取经验。
## 访谈上下文
- 课程主题:%s
- 受众:%s
- 已完成访谈的维度:%s
- 专家上一轮刚讲完:%s
## 即将进入的维度
- 维度名:%s
- 引导方向:%s
- 期望覆盖的要点:%s
## 任务
生成进入这个维度的第一个提问。
要求:
1. 与上一轮内容自然衔接(不要生硬换话题,可以用专家刚提到的线索引入)
2. 开放式提问,让专家讲具体做法或案例,不要问是非题
3. 一次只问一个问题(最多附带一个补充小问)
4. 口语化、亲切,像顾问面对面聊天,不要书面腔
5. 控制在 50 字以内
只输出问题本身,不要任何前缀和解释。
""",
session.getConfig().getTopic(),
session.getConfig().getAudience(),
session.getDimensionTree().getCompletedDimensionNames(),
lastExchange,
dim.getName(),
dim.getDescription(),
String.join(";", dim.getExpectedPoints())
));
}
/**
* 模式 2:深挖提问
*
* 核心技巧:从专家回答中识别"可追问线索"
* - 提到案例 → 追问具体细节和结果
* - 提到条件分支("看情况""如果是大客户")→ 追问其他分支
* - 提到模糊量词("大概""通常""有时候")→ 追问具体场景
* - 提到结果("效果不错")→ 追问量化数据
*/
public String buildDeepDiveQuestion(InterviewSession session, Dimension dim) {
DialogTurn lastTurn = session.getTurns().get(session.getTurns().size() - 1);
return llmClient.chat(String.format("""
你是一位企业知识访谈顾问,正在深挖"%s"维度的专家经验。
## 专家上一轮的回答
%s
## 该维度尚未覆盖的要点
%s
## 任务
分析专家的回答,选择最有价值的追问方向:
追问优先级(从高到低):
1. 回答中提到但没展开的具体案例 → "你刚才提到的那个案例,当时具体是怎么处理的?"
2. 条件分支只讲了一半 → "你刚才说大客户要慢,那小客户呢?"
3. 模糊表述 → "你说'效果不错',有没有印象深的一次?具体成单金额多少?"
4. 直接引向未覆盖要点 → "换个话题,关于X,你一般怎么做?"
要求:
1. 只选一个方向,只问一个问题
2. 顺着专家的话头追问,引用他刚说过的原话片段
3. 口语化,50 字以内
4. 如果专家的回答已经完整覆盖了当前要点,直接引向下一个未覆盖要点
只输出问题本身。
""",
dim.getName(),
lastTurn.getAnswer(),
String.join(";", dim.getUncoveredPoints())
));
}
/**
* 模式 3:查漏追问
*/
public String generateGapProbeQuestion(InterviewSession session) {
Dimension dim = session.getDimensionTree()
.getDimension(session.getCurrentDimensionId());
List<String> gaps = dim.getUncoveredPoints();
String gapHint = gaps.isEmpty()
? dim.getDescription()
: "该维度还有这些方面没聊到:" + String.join(";", gaps);
return llmClient.chat(String.format("""
你是一位企业知识访谈顾问,访谈接近尾声,做最后的补充提问。
## 访谈主题:%s
## 需要补充的维度和要点
维度:%s
%s
## 任务
生成一个收尾式的补充提问,帮专家把最后这块经验补上。
要求:
1. 提示访谈接近尾声,营造"最后几个问题"的轻松感
2. 聚焦缺口要点,一次最多问两个相关小点
3. 口语化,60 字以内
只输出问题本身。
""",
session.getConfig().getTopic(),
dim.getName(),
gapHint
));
}
}
六、增量知识抽取器:防幻觉的核心
这是整个引擎技术含量最高的部分。抽取器在每轮对话结束立即运行,从专家的口语回答中抽取结构化知识点,并挂接溯源引用。
6.1 知识点数据模型
/**
* 抽取出的知识点
*
* 关键设计:每个知识点必须挂溯源(出自第几轮对话的原文引用)。
* 这是防幻觉的根基——大纲中的每一条内容都能追溯到专家原话。
*/
@Data
public class ExtractedKnowledge {
private String id;
private String sessionId;
/** 知识点陈述(结构化的一句话,如"大客户报价应延迟,先建立需求紧迫感") */
private String statement;
/** 知识点类型 */
private KnowledgeType type;
// PRINCIPLE - 原则/方法
// CONDITIONAL - 条件分支("如果客户比价心理重,则...")
// CASE - 具体案例
// METRIC - 量化数据
// PITFALL - 踩坑提醒
// CHECKLIST - 操作步骤
/** 条件分支的上下文(type=CONDITIONAL 时) */
private String condition;
/** 案例描述(type=CASE 时) */
private String caseDescription;
/** 归属维度 */
private String dimensionId;
/** 溯源:出自第几轮对话 */
private Integer sourceTurnIndex;
/** 溯源:专家原话片段(verbatim quote,验证用) */
private String sourceQuote;
/** 置信度(抽取质量,低于阈值进人工确认队列) */
private Double confidence;
}
/**
* 大纲树节点
*/
@Data
public class OutlineNode {
private String id;
private String title; // 章节标题
private String dimensionId; // 来源维度
private List<ExtractedKnowledge> points; // 本章节知识点
private OutlineStatus status;
// GROWING - 访谈中持续生长
// COMPLETE - 内容充实
// GAP - 访谈提前结束,本章节内容不足,标记待补充
private List<OutlineNode> children;
}
6.2 抽取 Prompt 与置信度分级
/**
* 增量知识抽取器
*
* 每轮对话结束后立即执行。
*
* 三条铁律(写进 Prompt 的强约束):
* 1. 只抽取专家明说的内容,绝不推断、补充、美化
* 2. 每个知识点必须附带专家原话引用(sourceQuote)
* 3. 拿不准的内容标注低置信度,宁缺勿编
*/
@Service
public class IncrementalExtractor {
@Autowired
private LlmClient llmClient;
@Autowired
private EmbeddingClient embeddingClient;
public List<ExtractedKnowledge> extract(DialogTurn turn, InterviewSession session) {
Dimension currentDim = session.getDimensionTree()
.getDimension(session.getCurrentDimensionId());
String prompt = String.format("""
你是一位严格的企业知识整理专家。从下面这段访谈回答中抽取结构化知识点。
## 课程主题
%s
## 当前访谈维度
%s
## 访谈问题
%s
## 专家回答(口语原文)
%s
## 抽取规则(必须严格遵守)
1. 只抽取专家明说的内容。专家没说的,一个字都不能加。
- 专家说"报价前要摸预算",你不能抽成"报价前要通过旁敲侧击摸清客户预算"("旁敲侧击"是编的)
2. 每个知识点必须附带专家原话片段(quote 字段),原话必须逐字来自回答
3. 专家的口语表达可以在 statement 中规范化(去掉语气词、理顺语法),
但语义不能有任何增减
4. 条件分支要保留完整:"大客户别急着报,小客户要快"应抽取为
两个 CONDITIONAL 知识点(条件分别是大客户/小客户)
5. 案例和数字单独抽取(CASE/METRIC 类型)
6. 拿不准专家意思的,confidence 给 0.5 以下
7. 如果回答里没有实质内容(寒暄、跑题),返回空数组
## 输出 JSON 数组
[
{
"statement": "规范化后的知识陈述",
"type": "PRINCIPLE|CONDITIONAL|CASE|METRIC|PITFALL|CHECKLIST",
"condition": "条件(仅 CONDITIONAL 填)",
"caseDescription": "案例描述(仅 CASE 填)",
"quote": "专家原话片段(逐字引用)",
"confidence": 0.9
}
]
""",
session.getConfig().getTopic(),
currentDim.getName(),
turn.getQuestion(),
turn.getAnswer()
);
LlmResponse resp = llmClient.chat(prompt, Map.of(
"temperature", 0.1, // 低温度:抽取任务要稳定
"response_format", "json"
));
List<ExtractedKnowledge> knowledge = parseAndValidate(resp.getContent(), turn);
return knowledge;
}
/**
* 抽取结果校验:quote 是否真的逐字出现在专家回答中
*
* 这是防幻觉的第二道防线:LLM 声称的 quote 如果在原文中找不到
* (或相似度过低),说明它在编造,该知识点作废。
*/
private List<ExtractedKnowledge> parseAndValidate(
String llmOutput, DialogTurn turn) {
List<ExtractedKnowledge> result = new ArrayList<>();
List<Map<String, Object>> items = JSON.parseArray(llmOutput, Map.class);
for (Map<String, Object> item : items) {
ExtractedKnowledge k = new ExtractedKnowledge();
k.setStatement((String) item.get("statement"));
k.setType(KnowledgeType.valueOf((String) item.get("type")));
k.setSourceQuote((String) item.get("quote"));
k.setSourceTurnIndex(turn.getTurnIndex());
k.setConfidence(((Number) item.getOrDefault("confidence", 0.8)).doubleValue());
// 关键校验:quote 逐字匹配检查
String quote = k.getSourceQuote();
if (quote == null || !turn.getAnswer().contains(quote)) {
// 逐字匹配失败 → 降级用相似度检查
float[] quoteVec = embeddingClient.embed(quote);
float[] answerVec = embeddingClient.embed(turn.getAnswer());
double sim = cosineSimilarity(quoteVec, answerVec);
if (sim < 0.6) {
// 原话根本对不上:LLM 在编造,丢弃该知识点
log.warn("Hallucinated knowledge dropped: statement={}, quote={}",
k.getStatement(), quote);
continue;
}
// 相似但非逐字:降低置信度保留
k.setConfidence(k.getConfidence() * 0.7);
}
result.add(k);
}
return result;
}
}
防幻觉的"两道防线"设计:第一道在 Prompt 层(三条铁律 + 逐字引用要求),第二道在代码层(quote 逐字匹配校验,匹配失败降级到 Embedding 相似度校验,相似度过低直接丢弃)。第二道防线不依赖 LLM 自觉,是工程上的硬保障。
七、大纲渐进生成与提前结束处理
7.1 大纲渐进生成器
/**
* 大纲渐进生成器
*
* 大纲不是访谈结束后一次性生成的,而是随每轮抽取的知识点渐进生长。
* 好处:
* 1. 专家提前结束 → 大纲已经就绪(只差缺口标记)
* 2. 专家在收尾阶段看到的大纲是"生长中的成品",确认感更强
* 3. 单轮抽取失败不影响整体(重试该轮即可)
*/
@Service
public class OutlineGrower {
@Autowired
private LlmClient llmClient;
/**
* 每轮抽取后的知识点归入大纲
*/
public void ingest(InterviewSession session, List<ExtractedKnowledge> newKnowledge) {
OutlineTree outline = session.getOutline();
for (ExtractedKnowledge k : newKnowledge) {
// 找到知识点归属的章节(按维度映射)
OutlineNode chapter = outline.getOrCreateChapterByDimension(k.getDimensionId());
// 去重:与章节内已有知识点做相似度检查
boolean duplicate = chapter.getPoints().stream()
.anyMatch(existing ->
cosineSimilarity(
embeddingClient.embed(existing.getStatement()),
embeddingClient.embed(k.getStatement())
) > 0.85);
if (!duplicate) {
chapter.getPoints().add(k);
if (chapter.getStatus() == OutlineStatus.GROWING
&& chapter.getPoints().size() >= 3) {
chapter.setStatus(OutlineStatus.COMPLETE);
}
}
}
}
/**
* 生成章节标题(访谈结束后,为每个章节起一个专业的标题)
*/
public void polishTitles(InterviewSession session) {
for (OutlineNode chapter : session.getOutline().allChapters()) {
if (chapter.getPoints().isEmpty()) continue;
String title = llmClient.chat(String.format("""
为一门企业培训课程的章节起标题。
课程主题:%s
章节来源维度:%s
章节包含的知识点:
%s
要求:
1. 标题不超过 15 字
2. 概括本章节的核心内容
3. 培训课程风格(如"大客户报价的时机策略"是好标题,"报价"太简,"论大客户关系管理与报价策略的辩证统一"太长)
只输出标题。
""",
session.getConfig().getTopic(),
chapter.getTitle(),
chapter.getPoints().stream()
.map(ExtractedKnowledge::getStatement)
.collect(Collectors.joining("\n"))
));
chapter.setTitle(title);
}
}
/**
* 访谈提前结束:处理缺口章节
*
* 关键原则:诚实标注缺口,绝不编造补全。
* 缺口章节在大纲中显示为:
* - 标题照常显示
* - 内容区显示"本章节内容待补充(访谈未覆盖)"
* - 已有的少量知识点正常显示
*/
public void finalizeOutline(InterviewSession session) {
OutlineTree outline = session.getOutline();
for (OutlineNode chapter : outline.allChapters()) {
if (chapter.getPoints().size() < 2) {
// 知识点少于 2 条 → 标记为缺口章节
chapter.setStatus(OutlineStatus.GAP);
chapter.setGapNote("本章节在访谈中覆盖不足,内容待补充。可在课程编辑器中追加访谈或人工补充。");
}
}
// 生成大纲总览(课程简介)
String overview = llmClient.chat(String.format("""
基于以下章节和知识点,为这门课程写一段 100 字以内的简介。
课程主题:%s,受众:%s
章节结构:
%s
要求:简介只基于已抽取的知识点概括,不引入新内容。
""",
session.getConfig().getTopic(),
session.getConfig().getAudience(),
outline.summaryText()
));
outline.setOverview(overview);
}
}
八、访谈配置与全流程串联
8.1 访谈配置(对应产品"配置主题"环节)
/**
* 访谈配置:开访谈前由专家或管理员设置
*/
@Data
public class InterviewConfig {
/** 课程主题(如"大客户成单的报价策略") */
private String topic;
/** 受众群体(AI 生成开场确认问题用) */
private String audience;
/** 课程目标 */
private String goal;
/** 课程类型(决定维度树模板) */
private CourseType courseType;
// SALES_PLAYBOOK / AFTER_SALES_PROCESS / TECH_TROUBLESHOOTING
// PROJECT_RETROSPECTIVE / ONBOARDING_GUIDE
/** 最大访谈轮次(专家可调,默认 15 轮) */
private Integer maxTurns = 15;
/** 模型偏好(成本敏感场景可用轻量模型) */
private String modelPreference;
}
8.2 访谈开场:主题确认(对应产品“确认受众与目标”环节)
/**
* 开场阶段:AI 生成主题确认问题
*
* 与产品的"确认AI辅助生成的受众群体与课程目标"对齐——
* AI 根据主题生成建议的受众和目标,专家确认或修正。
*/
public String generateOpeningQuestion(InterviewConfig config) {
return llmClient.chat(String.format("""
你是一位企业知识访谈顾问。一场经验萃取访谈即将开始。
课程主题:%s
我为这门课初步设定的受众是:%s
课程目标是:%s
请生成开场白,包含:
1. 简短自我介绍(一句话:我是你的AI访谈助手,帮您把经验整理成课程)
2. 复述主题,确认受众和目标是否准确("这门课是讲给XX听的,目标XXX,对吗?有需要调整的地方吗?")
3. 说明访谈形式("接下来我会分几个方面向您请教,您想到哪说到哪,我会帮您整理")
要求:口语化、轻松,总共不超过 120 字。
""",
config.getTopic(), config.getAudience(), config.getGoal()
));
}
8.3 完整流程时序
专家 访谈引擎 下游(内容生产)
│ │ │
│ 配置主题/受众/目标 │ │
│─────────────────────────────▶│ 生成维度树(模板+LLM定制) │
│ │ │
│ 开场确认问题 │ │
│◀─────────────────────────────│ │
│ 确认/修正 │ │
│─────────────────────────────▶│ │
│ │ │
│ 维度1开启提问 │ ┌─── 每轮循环 ───┐ │
│◀─────────────────────────────│ │ │ │
│ 口语回答 │ │ 增量知识抽取 │ │
│─────────────────────────────▶│──▶│ 溯源校验 │ │
│ │ │ 大纲渐进生长 │ │
│ 深挖追问(顺着回答的线索) │ │ 覆盖率计算 │ │
│◀─────────────────────────────│ │ 状态机流转 │ │
│ ...N 轮... │ └────────────────┘ │
│ │ │
│ [可能] 专家提前结束 ──────────▶│ 缺口章节标记(不编造) │
│ │ │
│ 收尾:展示大纲摘要请确认 │ │
│◀─────────────────────────────│ │
│ 最终确认 │─────── 大纲就绪 ─────────────▶│
│ │ │ PPT生成/PDF导出
│ │ │ AI视频/发布课程
│ │ │ 归档知识库
九、访谈质量的量化评估
上线前需要一套评估体系来验证访谈引擎的实际效果。三个核心指标:
/**
* 访谈质量评估器
*
* 三个指标:
* 1. 维度覆盖率:大纲各章节的知识点充实度
* 2. 溯源通过率:知识点溯源校验(quote 逐字匹配)的通过比例——防幻觉的直接度量
* 3. 专家采纳率:专家在编辑环节对 AI 生成内容的修改/删除比例——内容忠实度的间接度量
*/
@Service
public class InterviewQualityEvaluator {
public InterviewQualityReport evaluate(InterviewSession session) {
// 1. 维度覆盖率
Map<String, Double> dimensionCoverage = session.getDimensionTree()
.allDimensions().stream()
.collect(Collectors.toMap(
Dimension::getName,
d -> session.getOutline()
.getChapterByDimension(d.getId())
.map(c -> Math.min(1.0, c.getPoints().size() / 4.0))
.orElse(0.0)
));
// 2. 溯源通过率
long total = session.getKnowledgePoints().size();
long passed = session.getKnowledgePoints().stream()
.filter(k -> k.getSourceQuote() != null
&& k.getConfidence() >= 0.7)
.count();
double traceabilityRate = total == 0 ? 0 : (double) passed / total;
// 3. 大纲完整度(非 GAP 章节占比)
double outlineCompleteness = session.getOutline().allChapters().stream()
.filter(c -> c.getStatus() == OutlineStatus.COMPLETE)
.count() * 1.0 / session.getOutline().allChapters().size();
return new InterviewQualityReport(
dimensionCoverage, traceabilityRate, outlineCompleteness);
}
}
内部测试的参考数据(不同课程类型各测 20 场访谈):
| 指标 | 数值 |
|---|---|
| 平均访谈轮次 | 16 轮(默认上限 15 + 追问 1-2 轮) |
| 维度平均覆盖率 | 0.82(销售话术类最高 0.89,项目复盘类最低 0.74) |
| 溯源通过率 | 94.2%(被代码校验拦截的编造知识点约 6%) |
| 专家采纳率(编辑环节改动 < 20% 内容的场次占比) | 78% |
| 访谈到大纲就绪的平均时长 | 35-50 分钟(对比人工课程开发 2-3 周) |
| 提前结束场景占比 | 约 25%(碎片化访谈是常态,功能设计必须支持) |
十、总结
企学宝技术团队经过多轮测试优化,总结出访谈式经验萃取的本质,是把"课程开发顾问"的角色工程化。三个最核心的设计经验:
访谈必须是状态机,不能是自由对话。 维度树给访谈提供了骨架和覆盖度度量,状态机按覆盖度驱动流转——该深挖时深挖、该跳过时跳过(维度受限)、该收尾时收尾。自由对话模式的 LLM 提问是随机游走,产出无法保证知识覆盖。
抽取必须每轮增量做,溯源必须逐字校验。 "最后一把整理全部对话"的方案既做不到随时结束,也让幻觉有了藏身之处。每轮即时抽取 + quote 逐字匹配校验,让每个知识点都有出处,编造内容被代码层硬性拦截——经验萃取的信任建立在"这确实是我说的"之上。
诚实优于完整。 提前结束标记缺口、维度受限主动跳过、专家没说的不补——这三个"克制"的设计比"硬凑一份完整大纲"更重要。专家第一次看到成品时发现 AI 编了他没说过的内容,这个产品的信任就再也建立不起来了。
如果你的企业也在做经验萃取类产品,落地顺序建议:先做维度树模板 + 状态机(保证访谈结构),再做增量抽取 + 溯源校验(保证内容忠实),最后做大纲渐进生成和缺口处理(保证体验完整)。前两步做完,引擎的核心竞争力就已经成型。
- 点赞
- 收藏
- 关注作者
评论(0)