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

LLM 擅长处理训练计划的形式:将训练课分配到各天、描述强度、解释练习。但连贯的文字并不等于连贯的方法论。模型本身并不保存运动员的最新背景,也无法确保赛季目标与明天训练课之间的连续性。在 1trAIner 中,我们通过分层规划、明确背景和生成之上的约束来解决这个问题。以下是该方法的架构、其保护机制和边界。

为什么单次请求行不通

“给我制定一个训练计划”这个请求不够明确。从中无法看出这个人昨天做了什么、明天有多少时间,以及最近一周与更长期目标有何关联。模型可能会用看似合理的假设来填补这些空白。结果会是一个工整的回答,但相对于运动员的真实情况,负荷选择将是随机的。

这个问题无法通过“做一个经验丰富的教练”这样的要求来解决。这样的指令设定了角色,但没有传递历史。即使详细的个人资料也无法替代最新的训练课和恢复指标。目标可能保持不变,但明天训练课的依据却可能改变。

还存在尺度上的差异。在赛季层面,需要的是整体备战构想。在日层面,则是与这一构想、当前状态和日程相兼容的具体训练。如果每个层面独立生成,它们之间就没有必然联系:周计划本身可能看起来合理,但并不是月度计划的延续。

因此,对架构的问题不是“如何写出最详细的提示词”,而是“在这个请求之前应该存在哪些决策”。使用 LLM 进行分层训练规划,就是明确设定这些依赖关系的方式。它不会让模型变得毫无差错,但不会把计划构建的顺序交给模型自行决定。

分层规划

服务中使用以下链条:

赛季 → 月度 → 周 → 日

下层建立在上层之上。每日计划不应作为关于合适训练课的独立创作出现。它的支撑是周计划,而周计划又拥有月度和赛季背景。

如果所需的上层范围不存在,服务会先将其补全。这一点与模型指令「考虑赛季」有重要区别:只能考虑实际传入的内容。缺失的计划是编排层的任务,而不是让模型去猜测内容。

还有一项额外检查:计划文本不得少于 300 个字符。占位文本「规划器未返回文本」不算作计划,也不能作为进入下一层范围的依据。

以下是对选择缺失支撑的示意,而非服务源代码的副本。这里的上下文用一个按范围存放计划的对象来示意:

// 哪个范围需要先于当前范围构建(null 表示无需构建)。
// 只向上看一级:周生成负责修补月计划。
const MIN_PLAN_TEXT_LENGTH = 300;

function planUsable(plan) {
  if (!plan || !plan.id) return false;
  return String(plan.markdown_text || '').trim().length >= MIN_PLAN_TEXT_LENGTH;
}

function missingParentScope(scope, ctx = {}) {
  if (scope === 'day') return planUsable(ctx.weekly_plan) ? null : 'week';
  if (scope === 'week') return planUsable(ctx.monthly_plan) ? null : 'month';
  return null;
}

这个函数很短,但它堵住了一整类错误:在它之前,每日计划可能

建立在空白的周计划之上,并直接在文本中如实写明这一点

任务。用户拿到的计划里,本该是训练课的位置却放着

一段解释,说明为什么没有训练课。

每日计划是提前在夜间生成的。这不是为了好看而做的优化:到

早晨推送时,文本必须已经存在,否则用户打开聊天窗口

还要等待生成。

我们在上下文中放入什么

任务的另一半是:生成时模型究竟看到什么。这里很容易

走极端:要么把所有东西都塞进去,为那些毫无影响的 token 付费,

要么为了省钱,得到一个脱离现实的计划。

每次请求都会重新收集上下文——这比一次性把

个人资料写进系统提示词更贵,但只有这样,模型回答的才是今天的你,而不是

一个抽象的运动员。

  • 最近的训练课,带日期和「今天 / 昨天」标记。没有明确日期,

模型会搞混顺序,把前天的训练当成刚完成的来分析。

  • 根据 Garmin 数据自动分析最新活动:心率区间、分段、

天气。正是这些信息把「跑了 10 公里」变成「在一周里第二次跑了 10 公里」。

教练看到的不是原始数据,而是已经整理好的摘要。

  • 恢复指标:睡眠、静息心率、心率变异性、

就绪度、训练负荷。关于明天训练的决定就基于这些指标,

强度。

  • 训练指标:乳酸阈心率与心率区间、各距离预测。

强度以这些指标为准,而不是网上的通用表格。

  • 一周饮食日记。只有一个用途:提醒

大训练量日前的能量补充不足。本服务不安排饮食方案。

  • 个人中心中的限制:不可用日期、每周可训练天数、

分钟上限。这些是硬性约束,不是建议。

  • 个人资料、目标与运动员记忆——很少变化的内容。

各项指标如何影响明天的安排

规则很简单,而且写得很明确:就绪度低、心率变异性下降、

睡眠不足或急性训练负荷偏高时,强度下调;

就绪度高时,允许进行高质量训练。没有“三周

在规划器里没有“一上一下”这种说法——负荷是阶梯式上升的,而

减量的时机取决于训练负荷、就绪度的变化以及

已完成训练课的质量。

这里的措辞准确性很重要:这些是模型之上的规则,而不是模型

自身的决定。模型用文字描述训练课,但“今天

可以上强度/今天只能轻松”这个框架不是它定的。

校验器:在用户看到计划之前,我们会检查什么

这里的每一项检查都是对某个具体事故的回应,而不是理论上的

谨慎。通用规则是这样的:如果模型的错误可能悄无声息地

传到用户那里,就需要一项检查让它变得显眼。

在模型回答和用户之间隔着好几道检查。

  • 依据是否适用。就是那个 300 字符的阈值:过短的回答会被判定为

是生成失败,而不是计划本身。

  • 补齐层级。如果没有周计划,会先补建到每日计划。
  • 硬性限制。锁定的日期和分钟上限没有商量余地:在

被标记为不可用的日期,不会安排训练课,即使按

方法论那里本应有训练。

  • 医学边界。疼痛、受伤、疾病症状——应当建议去

看医生,而不是自行调整负荷。本服务仅提供信息,不做诊断。

// 该计划能否作为下层级的支撑。
const usable = planUsable(ctx.weekly_plan);
if (!usable) {
  await generatePlanForScope('week', ctx);   // 先补齐周计划
}
await generatePlanForScope('day', ctx);     // 然后才生成每日计划

请求成本

上下文丰富的架构,先撞上的是成本,而不是质量:

教练的一次回复就是几千个输入 token。所以后台任务的

调度是由服务商的价目表决定的,而不是开发者的便利。

每次请求的上下文都要花钱,所以成本应当提前计算。截至

2026 年 9 月 10 日,该模型的价格方案如下:输入——每百万 token 0.15 美元,缓存

输入 — 每百万 $0.003,输出 — 每百万 $0.60。高峰时段价格翻倍

更高:分别为 $0.30、$0.006 和 $1.20。

按供应商价格表,高峰时段为工作日 UTC 01:00–04:00 和 06:00–10:00;周末

不计入高峰。因此每日计划在莫斯科时间 03:15–03:59 夜间生成:

按 UTC 属于非高峰,同样的工作成本减半。缓存输入

比普通输入便宜五十倍,因此上下文不变的部分适合

在每次请求间保持稳定。

第一次没成功的事

三个故事,每个都带来一次单独的修改。

第一个——在空白之上生成计划。周计划生成标记为已启动,计划

却没出现,而夜间生成每日任务时在空白的周计划

之上构建了任务,并直接在文本中写明了这一点。解决办法是检查所依赖的

文章开始。

第二点是文本占位符。回复“规划器未返回文本”在形式上

算作计划:有记录、有周期、有日期。因此设定了长度阈值。

第三点是语音识别。语音消息由基于 faster-whisper 的自有

服务进行转写。没有领域提示时,模型会把

“利基亚小径”自信地转成“莱基斯小径”,Body Battery 则转成什么都有可能。识别器提示中的

术语和专有名词列表帮上了忙。

下一步

近期工作与其说关乎模型,不如说关乎围绕它的检查:

更多针对计划结构和遵守个人中心限制的自动化测试。

不给出具体日期:已经运行的内容在

“教练如何做决策”页面有描述,而想尝试

可以通过首页或页面进入服务

跑步 AI 教练。

来源: Habr