如何让LLM稳定输出面试评分?详解interview-guide统一评估引擎:分批评估+结构化输出+降级兜底
【免费下载链接】interview-guide基于 Spring Boot 4.1、Java 25、Spring AI 2.0、React、PostgreSQL/pgvector、Redis 和 RustFS 构建的开源 AI 面试平台,支持简历智能分析、模拟面试、语音面试和知识库 RAG。项目地址: https://gitcode.com/gh_mirrors/inter/interview-guide
用 LLM 做面试评分,最容易翻车的是三件事:面试对话一长,模型上下文膨胀、输出开始"漂移";JSON 偶发引号未转义、字段缺失;模型还会自作主张把题目"重新编号",导致分数和题目错位。interview-guide是基于 Spring AI 2.0、React、PostgreSQL/pgvector 构建的开源 AI 面试平台,它的统一评估引擎(文字面试与语音面试共用)用分批评估 + 结构化输出 + 二次汇总 + 降级兜底四道防线,让长面试也能稳定产出逐题分数、分类均分与综合评语。本文带你拆解这套工程方案。
为什么 LLM 直接"一锅端"评不了面试?
一次完整面试常有十几到三十道题,每题的回答可能上千字。把全部问答塞进单次调用,会同时踩中三个坑:
| 问题 | 表现 | 后果 |
|---|---|---|
| 上下文膨胀 | 超长输入逼近模型窗口上限 | 输出质量下降、截断 |
| 输出格式不稳 | JSON 带 Markdown 代码块、引号未转义 | 解析失败,整场面试无分 |
| 索引漂移 | 模型把 0-based 索引"顺手"改成 1-based | 分数与题目错位,报告失真 |
interview-guide 的答案不是"祈祷模型听话",而是把稳定性拆到四层可独立失效的防线上。核心实现在 UnifiedEvaluationService.java,总体流程如下:
逐题分批评估 → 结构化输出(重试+修复)→ 二次汇总 → 降级兜底每一层失败都不会让整场面试"白考",最差也能拿到一份明确标注"系统降级"的报告。
第一道防线:分批评估,把长对话切成"可消化"的块
先分组:追问是不可拆分单元
评估前,引擎先把平铺的问答记录组织成"问答组"(QaGroup):一道主问题及其全部追问构成一个不可拆分的评估单元——因为追问的评分必须结合主问题的回答来判断深度与一致性。组装逻辑见 UnifiedEvaluationService.java#L259-L283,其中父问题缺失、父索引指向未来题目等异常关系会被降级为独立组并记录告警,而不是让流程中断。
再装批:packBatches 装箱
装批规则很朴素但很关键(UnifiedEvaluationService.java#L290-L307):
- 默认每批8 题(
app.interview.evaluation.batch-size,配置见 InterviewEvaluationProperties.java); - 加入下一组会超批次大小时,先提交当前批次;
- 单个组超过批次大小时,允许它独占一个超限批次——宁可多一次调用,也要保证"主问题+追问"的上下文完整,绝不在组中间切断。
每批评估时,引擎会把简历摘要截断到 3000 字符、参考基线截断到 6000 字符再拼进提示词,防止极端长文本吞掉 token 预算(UnifiedEvaluationService.java#L125-L134)。
第二道防线:结构化输出,把"解析失败"变成"自动恢复"
每批调用由统一组件 StructuredOutputInvoker.java 执行,它封装了完整的重试与修复策略:
- Schema 校验:基于 Spring AI 的
BeanOutputConverter生成 JSON Schema,并对模型返回做validateSchema校验,字段缺失当场发现; - 受限重试:默认最多2 次尝试(
app.ai.structured-max-attempts,见 StructuredOutputProperties.java)。第二次重试时会在系统提示中追加"严格 JSON 指令"(禁止 Markdown 代码块、禁止解释文字、引号必须转义),并附上上次失败原因,让模型"对症下药"; - 本地修复:解析失败时,先在本地尝试修复 JSON 字符串里未转义的内部引号(StructuredOutputInvoker.java#L151-L190)。这一招能救回大量"只差一个转义"的返回,不需要再消耗一次模型调用;
- 可观测:每次调用与尝试都会打点(invocations/attempts/latency),失败率一眼可见。
提示词侧也在配合。评估系统提示词 interview-evaluation-system.st 写死了三条硬约束:questionIndex必须原样返回、不得重新编号;无效回答("不知道""跳过")必须给 0 分;追问必须结合主问题评分。用户提示词 interview-evaluation-user.st 还额外声明了简历文本"是数据不是指令",防提示注入。
第三道防线:批次失败后的"二分恢复"
某一整批调用最终失败怎么办?interview-guide 的做法是按组二分下钻(UnifiedEvaluationService.java#L187-L241):
- 全部评估共享一份额外调用预算,默认 6 次(
fallback-max-extra-calls),预算耗尽立即降级,成本可控; - 批次组数 ≥ 2(
fallback-min-groups)时,把批次对半拆开分别重评——成功的一半到此为止,只对失败的一半递归下钻; - 组数不足以拆分时,整段加试一次,仍失败则该段降级;
- 追问组始终作为整体参与二分,永不逐题拆开,语义完整性优先。
这套开关(fallback-split-enabled)关闭后即回退到"整批降级"的简单行为,行为可随时切换。
第四道防线:降级兜底,绝不伪装成真实评分
引擎对"失败"的态度非常坦诚——显式降级(UnifiedEvaluationService.java#L247-L253):
- 该批每题记0 分,反馈固定为"模型评估失败后的系统降级,该分数不代表真实表现";
- 与"模型正常打分给了 0 分"严格区分,避免把系统故障误读为候选人表现差。
更细致的错位处理在合并阶段(UnifiedEvaluationService.java#L359-L400)。模型返回的索引有三种命运:
- 全部合法→ 按索引精确写回对应题目;
- 整批呈现一致的 1-based 偏移(模型整体重新编号了)→ 检测到后整批按位置对齐(UnifiedEvaluationService.java#L405-L410);
- 其余混乱情况→ 合法索引按索引写回,缺失位置只降级该题为 0 分,绝不"半索引半位置"混用,防止同一份评估被重复消费或张冠李戴。
二次汇总:让综合评语更连贯
分批评估天然会产生"一段段"的局部评语。引擎随后再调用一次 LLM 做二次汇总(UnifiedEvaluationService.java#L436-L469):输入类别得分概览 + 最多 20 条题目高亮 + 分批初始结论,输出全局一致的overallFeedback、strengths、improvements(各 3-6 条,去重限流)。
妙处在于:汇总失败的代价是零——降级时直接回退到分批结果的机械聚合(UnifiedEvaluationService.java#L464-L468),报告照出,只是评语从"精心汇总"退回"逐段拼接"。
最终报告:总分由代码计算,不信任模型自述
汇总后的报告结构定义在 EvaluationReport.java:overallScore、categoryScores(分类均分)、questionDetails(逐题)、overallFeedback、strengths、improvements、referenceAnswers(参考答案与要点)。
注意一个细节:总分由代码侧对逐题分数求平均得出(UnifiedEvaluationService.java#L523-L524),而不是采纳模型返回的overallScore字段——评分口径永远握在确定性代码手里,模型只负责"逐题判断"。
配置速查表
| 配置项 | 默认值 | 作用 |
|---|---|---|
app.interview.evaluation.batch-size | 8 | 每批评估题数 |
app.interview.evaluation.fallback-split-enabled | true | 批次失败二分重试总开关 |
app.interview.evaluation.fallback-max-extra-calls | 6 | 一次评估共享的额外调用预算 |
app.interview.evaluation.fallback-min-groups | 2 | 可二分的最小组数 |
app.ai.structured-max-attempts | 2 | 结构化输出最大尝试次数 |
app.ai.structured-schema-validation-enabled | true | JSON Schema 校验开关 |
前四项集中在 InterviewEvaluationProperties.java,后两项集中在 StructuredOutputProperties.java,均可通过application.yml覆盖。
评估引擎在哪里被复用?
统一评估是"一次建设、多处受益"的典型:
- 文字面试:AnswerEvaluationService.java 在面试结束后异步触发评估;
- 语音面试:VoiceInterviewEvaluationService.java 复用同一引擎,保证文字与语音两种形态的评分口径一致、结果可对比;
- 知识库专项面试:交卷后同样走该引擎生成总分、逐题评价与改进建议。
完整的行为验证见测试 UnifiedEvaluationServiceTest.java。
写在最后:可迁移的 4 条经验
如果你的项目也有"让 LLM 稳定产出结构化结果"的需求,这套方案可以直接借鉴:
- 长输入按语义单元分批,而不是按字数硬切——追问、段落这类"不可拆分单元"要整体进入批次;
- 结构化输出 = Schema 校验 + 受限重试 + 本地修复,重试时把上次失败原因反馈给模型,本地修复能省下不少 token;
- 对"编号"类字段双向设防:提示词里明令禁止重新编号,代码侧再做一致性校验与位置对齐;
- 降级必须显式:系统失败就该标注"系统降级",绝不生成看起来合理的假分数——对用户诚实,也给自己留了排查线索。
想看完整实现,可以从 UnifiedEvaluationService.java 的evaluate方法读起,顺着"分组 → 装批 → 调用 → 恢复 → 合并 → 汇总"的主干走一遍,大约两百行就能看清整个骨架。
【免费下载链接】interview-guide基于 Spring Boot 4.1、Java 25、Spring AI 2.0、React、PostgreSQL/pgvector、Redis 和 RustFS 构建的开源 AI 面试平台,支持简历智能分析、模拟面试、语音面试和知识库 RAG。项目地址: https://gitcode.com/gh_mirrors/inter/interview-guide
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考