[控场AI]
· 7 分钟阅读· 3,528 字

别让大模型算数:AI评语言,代码算分数

别让大模型算数:AI评语言,代码算分数

让LLM做语言判断、让代码做计算,是构建可靠AI评分系统的核心架构原则。

本文以德语B1/B2考试备考平台ExamReady的写作评分功能为案例,阐述了一条清晰的AI应用架构原则:LLM擅长语言理解与主观判断,但在计数和算术上存在不可忽视的偶发错误,因此应将两类任务严格拆分。实践中,模型只负责给出各标准的评分和文字反馈,单词计数、硬规则应用(如字数不足判0分)和总分计算全部由代码完成。此外,模型输出应被视为需要校验的输入:分数要clamp到合法区间,每条批注在展示前须与原文核对。这种"克制而工程化"的职责划分,换来了可复现、可测试、可调试的系统可靠性。

把一篇作文丢给语言模型,让它既做语言评判、又数单词、算总分、套规则,它通常会给你一个自信满满的答案。问题在于,这个答案有时是错的,而你很难察觉——因为输出看起来太合理了。

这是开发者在构建德语 B1/B2 考试备考平台 ExamReady 的写作与口语练习功能时遇到的真实困境。他最终采用的架构可以用一句话概括:让 LLM 做裁判,让代码做一切可以精确计算的事。本文拆解这条分界线划在哪里,以及为什么要这样划。

问题的本质:评判与算术是两回事

在写作考试里,考生拿到任务、写一段文本,然后按多项官方标准被打分。但分数之外还有一些硬规则。比如:如果文本字数不足要求字数的 50%,直接判 0 分。

这条规则与语言质量毫无关系,它纯粹是算术。一篇写得很好的文章,也可能因为太短而得 0 分。

AI 评分与代码计分的架构拆分

最诱人的做法,是把整份评分指南塞进一个 prompt,让模型直接产出最终分数——一次 API 调用,一个结果,对独立开发者来说极具吸引力。但这种做法混淆了两种性质截然不同的任务。

模型擅长什么,不擅长什么

模型擅长的是需要语言理解的判断:这篇文章是否切题?语体是否恰当?语法是否基本正确、错在哪里?这些正是我们需要 LLM 介入的地方。

但模型在计数和算术上并不可靠。数一篇文章有多少个单词、判断它是否低于要求字数的一半、把各项标准分加总成总分——这些事情,几行代码每次都能精确完成。让模型来做,得到的结果往往是对的,却偶尔会以一种难以察觉、无法复现的方式出错。

这个区别决定了架构的拆分方式。

这一现象在 AI 领域被称为"幻觉"(hallucination),但计数错误更接近另一个问题:大型语言模型本质上是基于 token 概率预测的系统,而非执行精确符号运算的引擎。单词计数看似简单,但模型实际处理的是 token 序列(一个单词可能对应多个 token,标点、空格也会影响切分),要从中准确还原"空格分隔的单词数"并不是它被训练来优化的目标。类似地,"各项得分求和"这种操作,模型并不像 CPU 那样逐步执行加法,而是通过训练中见过的模式来"猜"答案,偶发错误因此难以避免。这也解释了为什么这类错误难以通过改进 prompt 彻底消除——问题不在于表达是否清晰,而在于任务的性质与模型的工作机制存在根本性错配。

分工:模型给评语,代码给分数

在实际实现中,LLM(这里用的是 Gemini)接收任务和考生文本,返回每项官方标准的评分加上文字反馈。它不返回总分,也不判断文本是否够长。

模型输出的结构大致如下:

type CriterionGrade = {
  criterion: string;     // 官方标准之一
  points: number;        // 在该标准允许的区间内
  comment: string;
};

type ModelGrading = {
  grades: CriterionGrade[];
  feedback: string;
  corrections: { original: string; suggestion: string }[];
};

随后由代码接手,完成三件事:数单词、套用官方规则(如字数不足 50% 判 0 分)、将各项评分合成最终结果:

function countWords(text: string): number {
  return text.trim().split(/\s+/).filter(Boolean).length;
}

function finalScore(text: string, task: WritingTask, g: ModelGrading) {
  const words = countWords(text);

  // 规则由代码应用,而非模型
  if (words < task.requiredWords * 0.5) {
    return { total: 0, reason: "too_short", words };
  }

  const total = g.grades.reduce((sum, c) => sum + clamp(c.points, task), 0);
  return { total, words };
}

这里有两个关键细节。其一,单词计数是一个可以单元测试的函数——你可以喂给它各种文本并检查结果,而 prompt 做不到这一点。其二,模型给出的分数会被 clamp 到每项标准允许的区间,因为模型可能返回超出量表的值。换句话说,模型的输出被当作需要校验的输入,而不是最终裁决。

校验批注:防止模型引用不存在的句子

第二个停止盲目信任模型的地方,是批注(corrections)功能。一个有用的反馈特性,是把考生自己句子里的具体错误指出来,并给出更好的版本。但这里有个典型失败模式:模型引用了考生从未写过的句子,或者在"纠正"之前悄悄改写了原句。

解决办法是:每一条批注在展示前,都要和考生原文核对。核心逻辑非常简单:

function verifiedCorrections(text: string, list: ModelGrading["corrections"]) {
  return list.filter((c) => text.includes(c.original));
}

如果被引用的片段实际上不在原文里,这条批注就被丢弃。缺一条批注,总比指着考生没写过的句子给出错误批注要好。真实实现对空白字符会更宽容一些,但原则一致:代码负责核对模型的声称是否属实。

这类问题源于 LLM 生成文本的方式:模型在生成"原句"字段时,是基于上下文概率续写,并非在做字符串查找。它可能输出语义上接近、但字面并不存在于原文的片段,或者在引用时悄悄修正了拼写、调整了语序。对于批改场景而言,这种"合理但不准确"的引用尤其危险——考生看到一个根本没写过的句子被指出错误,不仅困惑,还会对整个系统的可信度产生怀疑。text.includes(c.original) 这行代码做的事情极其简单,却是系统可信度的重要保障,它把"模型相信自己引用了什么"和"原文里实际存在什么"这两件事重新区分开来。

为什么不干脆全交给 prompt?

这个问题值得正面回答。现代模型确实很强,大多数时候它们能数对单词。但对于一个会影响"考生是否认为自己能通过考试"的系统来说,"大多数时候"不是可接受的标准。作者给出了几条偏好拆分的理由:

  • 可复现性:同一篇文本永远得到相同的单词数和相同的规则应用结果,只有判断部分会浮动。
  • 可测试性:代码里的规则可以写测试,prompt 里的规则只能靠"感觉"。
  • 可调试性:当分数看起来不对时,能分清是规则出错还是模型判断出错。
  • 规则变更:规则变了,改一个函数即可,不必重新验证整个 prompt。

代价是更多代码和更结构化的模型输出。相较于它换来的东西,这个代价很便宜。

口语场景:边界仍在摸索

口语练习用的是基于 Gemini Live 的实时对话伙伴。这是另一类问题——它是对话而非文档,每个口语任务大约花费十美分。作者坦言自己仍在摸索这里正确的边界在哪里,因此这篇文章只聚焦写作场景。这也从侧面说明:"LLM 判断 + 代码计算"的拆分哲学虽然清晰,但在不同任务形态下的落点并非一成不变。

核心启示

整套方法论可以浓缩为几条简单原则:

  1. 让 LLM 判断语言,让代码去数、去比、去加总。
  2. 把模型输出当作不可信输入:校验数值范围和数据结构。
  3. 官方规则用代码实现,这样才能测试和复现。
  4. 任何引用或批注,展示前先与考生原文核对。
  5. 宁可少给一条反馈,也不要给错误的反馈。

正如作者所说,这些做法谈不上有多聪明,核心无非是:别让语言模型去做一个函数本来就做得更好的事。在 AI 应用越来越倾向于"大力出奇迹"的今天,这种克制而工程化的职责划分,反而是构建可靠产品的关键。

#LLM应用#AI评分#大模型可靠性#AI工程实践#Gemini#模型输出校验#AI编程#语言模型局限
分享:

相关推荐