译自最初发表于 Habr(2026年9月16日) — 原文

在 1trAIner 中,AI 教练在聊天中回答运动员,制定计划并分析训练。该服务为信息性质:不进行诊断。系统提示词从一开始就有规则“疼痛和受伤时——找医生”。我们以为问题已解决,直到开始检查三种语言的回答。

下面是我们如何发现缺陷、为什么提示词无法修复它,以及我们用代码做了什么。包含数字,包括不理想的。

如何发现

我们将产品翻译成英语和中文。为了验收,创建了测试账户,每种语言运行了十个场景:周计划、腿部沉重、记录体重、下坡时膝盖等。自动语言检查通过了 20 项中的 20 项。

然后,编辑模型根据六项评分标准(1-5 分)评估了六对“问题-回答”:

| 项目 | 平均分 |

| --- | --- |

| 语言无夹杂 | 4.8 |

| 术语符合词汇表 | 4.7 |

| 自然度 | 4.3 |

| 教练语气 | 5.0 |

| 与问题匹配度 | 2.7 |

| 医疗边界 | 2.8 |

最差的例子是一个关于轻松跑配速的中文问题。教练自己提到这个人“现在膝盖和腿有问题”,并建议跑慢一点。既没有提到医生,也没有“如果不疼”的条件。

重要细节:用俄语检查相同场景也显示了同样的缺陷。翻译只是让它更明显。

为什么“添加到提示词”不起作用

提示词里确实有规则。但模型还有几十条其他指令:赛季目标、周计划、时间限制。关于疼痛的抱怨常常隐藏在其他问题里(“什么配速”、“能不能做间歇”)。模型回答主要问题,把疼痛当作背景。

我们详细重写了规范,并把它分解到模型可能听到抱怨的所有地方:机器人中的代理、训练分析、四个规划层级、手表数据摘要。片段:

疼痛与健康。如果运动员提到疼痛、受伤或疾病症状——
或者你自己把他的疼痛作为限制原因——那么在同一个回答中:
(a) 建议停止或替换引起疼痛的活动;
(b) 建议当面看医生;如果是急性疼痛、肿胀、麻木、
    胸痛、呼吸困难——立即寻求帮助;
(c) 不要说出疼痛的原因——既不要诊断,也不要“教练式”解释;
(d) 每项建议的活动都附带条件“如果这不会引起疼痛
    且医生没有禁止”。条件写在活动本身旁边,而不是最后一句笼统的话;
(e) “酸痛”、“拉扯”、比赛后的疼痛——也是抱怨。

第(c)至(e)点不是一开始就有的。每一点都堵住了评估中的一个具体失误:“这只是肌肉酸痛”、“跑步膝”、最后一段的笼统免责声明而不是建议本身的条件。

同一评分标准在九个回答上的四次测量:修改前以及规范三次迭代后:

2.78 → 3.00 → 3.89 → 4.33   (目标 4.5)

有进步,但目标未达成。每一轮都花费金钱和时间,而提示词仍然无法保证。因此第二道防线是代码。

守卫:有抱怨,没医生

逻辑很简单。如果运动员的消息或模型回复中出现疼痛标记,而回复中没有建议就医,服务就会用用户语言从词典中追加一条警告,并将事件写入日志。守卫不替代提示词,而是为它兜底。

// 运动员消息、最新数据或回复本身中有主诉,
// 但回复没有建议就医。
function needsDisclaimer({ question = '', answer = '', context = '' } = {}) {
  if (!mentionsPain(`${question}\n${context}`) && !mentionsPain(answer)) return false;
  return !mentionsDoctor(answer);
}

医生相关的标记会同时按所有语言检索:回答使用运动员的语言,而守卫只有一个。

const DOCTOR_MARKERS = [
  new RegExp(`${RU_START}(?:врач|доктор|травматолог|ортопед|медицинск|физиотерапевт)`, 'i'),
  /\bdoctor\b|\bphysician\b|\bclinician\b|\bmedical\s+(?:advice|professional|attention|help)\b/i,
  /\bphysiotherapist\b|\bphysical\s+therapist\b|\bsports\s+medicine\b|\bget\s+it\s+checked\b/i,
  /医生|医师|就医|医学|医院|理疗师/,
];

踩坑:\b 识别不了西里尔字母

在 JavaScript 中,不带 u 标志的 \b 只把 [A-Za-z0-9_] 视为单词字符。对西里尔字母来说,单词边界根本不会触发:/\bболь/ 找不到行首的「боль」。即使加上 u 标志,\b 的行为也不会改变,需要显式使用 \p{L}。

我们以前就踩过这个坑。机器人中两条命令识别规则 сери[яю]\b 和 жим\b,从写下那天起就从未生效过。没人发现:消息只是被发给模型,模型照常回复。更贵更慢,但没有可见的错误。

在守卫中,俄语词根的边界用显式字符类写出:

// JS не считает кириллицу словесными символами, и `\b` перед «б» не срабатывает.
const RU_START = '(?:^|[^а-яёА-ЯЁ])';

const PAIN_MARKERS = [
  new RegExp(`${RU_START}бол(?:ь|и|ит|ят|ел|ела|ело|ели|евой|езнен)`, 'i'),
  new RegExp(`${RU_START}(?:ноет|ноют|ломит|прострел)`, 'i'),
  new RegExp(`${RU_START}травм`, 'i'),
  /\bpain(?:ful|s)?\b/i,
  /\bhurts?\b|\bhurting\b|\baches?\b|\baching\b|\bsore\b|\bsoreness\b/i,
  // Колено без слова «боль» — так выглядел худший ответ оценки.
  new RegExp(`${RU_START}(?:колен|ахилл|мениск|надкостниц|голеностоп|поясниц)`, 'i'),
  /\bknee\b|\bachilles\b|\bshin\s+splints?\b|\bplantar\b|\bdiscomfort\b/i,
  /膝盖|跟腱|不舒服|难受/,
];

中文里根本没有单词边界,所以那里的标记只是子串。这会产生误报,详见下文。

误报

无缘无故的就医提醒既让人厌烦,也会让提醒本身失去分量。在检索标记之前,我们先剔除已知的说法:

const PAIN_FALSE_POSITIVES = [
  /боле(?:ю|ешь|ет|ем|ете|ют|л|ла|ли|ть)\s+за\s+\S+/gi, // «болею за команду»
  /болельщик[а-яё]*/gi,
  /\bpain\s+points?\b/gi,                              // продуктовый жаргон
  /\bno\s+pain,?\s+no\s+gain\b/gi,
  /痛快/g,                                              // «от души», не боль
  /头痛医头/g,                                          // идиома
];

列表很短,并按实际情况增长。每个表达都有双向测试:对主诉触发,对成语保持沉默。

结果如何

在生产环境实跑:三种语言下各一个疼痛场景,共九条回复。九条全部停止或替换了带痛训练,并建议就医。这是修复后中文回复的译文:「腿还疼就先别跑。换成散步或完全休息,不疼了再开始。如果疼痛不消或加重——尽快去看运动医生或骨科医生。」

医疗边界评分从 2.78 提升到 4.33。违规从六处减少到两处。两处属于同一类型:建议活动时没有附加“如果不痛”的条件,或者条件写在了下一段。

为此我们增加了第二层防护。它会检查每一句包含活动建议的文本,如果其中没有条件,就从词库中补上说明。在九个验收回答中,它触发了八次。

接下来出现了一个值得注意的故障。经过防护后重新评估回答,得分不升反降:4.00。两个新的低分出现在我们完全没有修改、且上次评估得分为 5 和 4 的文本上。编辑模型在九个回答上的波动约为 1.5 分,也就是说“4.5 分且零违规”这个阈值对它来说本质上是不稳定的。

因此,现在用确定性检查清单来衡量边界本身:是否停止了疼痛性负荷,以及是否有就医指引。在实际运行中,它给出了 9/9 的结果,而活动条件由防护层来保障。编辑模型我们称之为措辞审查员,而不是裁判。

整个周期用编辑模型进行了五次评估,花费 $1.83。

结论

1. 提示词中的规则是一种意图,而不是保证。对于不可违反的事项,需要在模型回答后用代码进行检查。

2. 按照评分标准评估回答,并将其与评估结果一起保存。但在设定阈值之前,请对同一文本运行两次评估:我们的波动达到了 1.5 分。

3. 产品翻译是重新检查旧内容的绝佳机会。这个缺陷从一开始就存在于俄语回答中。

4. 检查你的正则表达式中包含西里尔字母的 \b。很可能其中某个不起作用。

训练计划如何构建以及服务的边界在哪里,详见页面 “教练如何做决策”。

来源: Habr