访谈式 AI 经验萃取引擎设计:多轮对话引导与结构化知识抽取的工程实现

举报
Jucai_SZ 发表于 2026/09/15 11:15:36 2026/09/15
【摘要】 企业里最值钱的培训内容,往往锁在资深员工的脑袋里——Top Sales 的成单话术、老工程师的故障排查直觉、项目老手的复盘教训。传统的方式是请课程开发顾问做访谈、整理、编写,一门课要几周时间和不菲的咨询费,结果是绝大多数经验从未被沉淀。AI 经验萃取要解决的就是这个问题:让 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 编了他没说过的内容,这个产品的信任就再也建立不起来了。

如果你的企业也在做经验萃取类产品,落地顺序建议:先做维度树模板 + 状态机(保证访谈结构),再做增量抽取 + 溯源校验(保证内容忠实),最后做大纲渐进生成和缺口处理(保证体验完整)。前两步做完,引擎的核心竞争力就已经成型。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。