agno 环境评估实战:用确定性数学任务把模型校准到"学习区"(_21_math)
【免费下载链接】agnoBuild, run, and manage agent platforms.项目地址: https://gitcode.com/GitHub_Trending/ag/agno
本文基于cookbook/environments/_21_math/的测试日志(TEST_LOG.md)与配套脚本,讲解 agno 的 Environment/Task/CodeScorer 组件如何用于"精确可验证"的数学评估:如何用饱和乘法与模运算递推构造出真正出现分歧的通过率先验,并基于仓库源码(libs/agno/agno/environments/、libs/agno/agno/scorer/)解释run_rollouts的执行语义与pass_rate的统计口径,读完后可直接复制运行三组校准任务并读懂输出网格。
一、为什么用数学题做评估:精确验证、无需判分
该目录的定位写在 README.md 中:
Verify exact numerical answers with typed output and deterministic code. Single products saturate strong models, so these tasks grow through digit transforms and modular recurrences until attempts genuinely disagree.
核心思路分三层:
- 答案可被程序判定:最终输出是单个整数,由
CodeScorer直接比对expected,不存在"判分主观性",只有对与错; - 强模型会被简单题"刷爆":像
17 x 23这种单步乘法,强模型 6 次尝试全对(饱和),通过率为 1.0,这样的行(row)对校准毫无信息量; - 逐步加码直至出现分歧:通过"数字和变换 × 放大系数 − 模运算调整"、以及"重复平方 + 取模的非线性递推"拉长计算链,让模型尝试真正出现
0 < pass_rate < 1的中间带,这一行才落在"学习区"(learning zone)内。
TEST_LOG.md 记录了 2026-07-20 的一次真实运行:gpt-5.5、agno 2.7.4、.venvs/demo/bin/python解释器、OPENAI_API_KEY从.envrc加载,三个脚本全部 PASS。下文的通过率先验均出自该次运行。
二、三个校准脚本:任务设计与实测结果
三组脚本共享同一套骨架:一个带output_schema的Agent、一个包含若干Task的Environment、一个精确比较的CodeScorer,最后run_rollouts(env, k=6, concurrency=6)并行执行 6 轮尝试。
2.1 basic.py — 饱和乘法 vs 学习区递推
basic.py 把两个任务放在同一个环境里做对照:
class Answer(BaseModel): value: int def exact(run, expected): return run.content.value == expected agent = Agent( model=OpenAIResponses( id="gpt-5.5", reasoning_effort="low", verbosity="low", max_output_tokens=3000, ), instructions="Compute exactly without external tools and return the final integer.", output_schema=Answer, ) env = Environment( name="math-basic", agent=agent, tasks=( Task(id="single-product", input="What is 17 x 23?", expected=391), Task( id="recurrence-8", input=( "Let a0=271828. For n=1 through 8, set " "a_n=(a_(n-1)^2 + 97*n + 31) mod 10000019. Return a_8." ), expected=6856135, ), ), scorer=CodeScorer(exact), )两个任务的设计意图差异巨大:
single-product:17 x 23,期望值391。这是用来演示饱和的对照组——强模型下它必然 6/6 全对;recurrence-8:8 轮"平方 + 线性项 + 取模"递推(模数10000019,初值a0=271828),每一步都必须精确持有上一轮状态,期望值6856135。
实测结果(来自 TEST_LOG,12 次尝试、29 秒):
| 任务 | 通过率先验 | 位置 |
|---|---|---|
single-product | 6/6 = 1.0 | 饱和区(无信息量) |
recurrence-8 | 5/6 ≈ 0.833 | 学习区(有分歧) |
这组对照正是该目录 README 的第一条原则:把饱和乘法与"真正的学习区递推"并排放置,才能看到从易到难的可见跃迁。
2.2 chained_arithmetic.py — 用组合操作替代单步乘法
chained_arithmetic.py 的思路是:单个操作都不难(乘法、各位数字求和、乘系数、取模),但串联后模型必须把精确乘积一路"扛"过四个变换。验证器依旧只比较一个整数,难度却来自中间状态的精确传递:
Task( id="short-chain", input=( "Compute 9999999967 x 9999999973. Add every decimal digit of " "the product, multiply that sum by 104729, then subtract the " "product remainder modulo 7919." ), expected=9838934, ), Task( id="long-chain", input=( "Compute 2718281828459045 x 1618033988749895. Add every " "decimal digit of the product, multiply that sum by 131071, " "then subtract the product remainder modulo 65521." ), expected=20944939, ),两条链的结构完全一致(乘 → 数字和 → 乘 131071/104729 → 减模余数),差别只在操作数长度:long-chain使用 16 位十进制数。
实测结果(12 次尝试、56 秒):
| 任务 | 通过率先验 |
|---|---|
short-chain | 6/6 = 1.0(短链饱和) |
long-chain | 4/6 ≈ 0.667(落入中间带) |
脚本结尾还展示了"学习区任务选择"的标准写法(chained_arithmetic.py):
middle = [ task.task.id for task in results.task_results if task.pass_rate is not None and 0 < task.pass_rate < 1 ] print(f"partial pass-rate tasks: {middle}")按 TEST_LOG 记录,这次运行中该显式0 < pass_rate < 1筛选只返回long-chain——这正是校准的目标产物:从任务集里挑出"既有失败又有成功"的行。
2.3 recurrence_boundary.py — 递推轮数的边界扫描
recurrence_boundary.py 只改变一个变量:递推轮数。同一公式a_n = (a_(n-1)^2 + 97*n + 31) mod 10000019(初值271828)分别跑 8、9、10 轮,得到三行确定性任务,期望值分别为6856135、7826798、542370。脚本的instructions也从通用的 "Compute exactly..." 换成了更聚焦的 "Compute the recurrence exactly and return the final integer."
实测结果(18 次尝试、82 秒,全部被评分):
| 任务 | 通过率先验 |
|---|---|
rounds-8 | 3/6 = 0.500 |
rounds-9 | 3/6 = 0.500 |
rounds-10 | 4/6 ≈ 0.667 |
三个值全部落在中间带。TEST_LOG 还给出了一条重要的负面经验:早期废弃的 11 轮、15 轮校准任务出现过"30 秒以上未完成"(超时不评分)的行为,把行挤出可评分区间;而 8–10 轮区间没有这种现象。这说明难度不是单调越大越好——轮数过长会让尝试超时(Environment.timeout_seconds默认 120 秒,见下文源码),评分样本减少,网格反而失真。
三、源码级解读:结果是怎么算出来的
3.1 Task 与 Environment 的数据结构
任务行定义在 environment.py:
class Task: input: str expected: Optional[Any] = None id: Optional[str] = None metadata: Mapping[str, Any] = field(default_factory=dict)input是发给 Agent 的提示词;expected交给评分器比对(本目录三个脚本都是整数);id用于展示与选择;缺省时在运行开始按位置自动分配t1..tN;- 环境是
frozen=True的数据类(environment.py),字段包括name、tasks、scorer、agent和timeout_seconds: int = 120。冻结语义保证"结果确实来自这套任务集 + 评分器 + 策略对象";构造时还会做两项校验:task.id重复直接报错(否则 diff 时行会错位配对),且Environment.agent不接受Team(团队环境要等团队版发布)。
3.2 CodeScorer:bool / float / Score 三种返回的精确语义
exact(run, expected)之所以写成run.content.value == expected,是因为在声明了output_schema=Answer之后,run.content不是字符串而是 Pydantic 模型。这一约定在 code.py 的文档串中被明确推荐:
run.contentisAny, notstr: underoutput_schemait is a pydantic model, and comparing a typed field against the expected value is the recommended shape.
CodeScorer的评分转换规则(_to_score,code.py):
- 返回
bool:映射为Score(1.0, True)/Score(0.0, False),不走阈值判断(注意 bool 先于 float 判断,因为 bool 是 int 的子类); - 返回
float(或 int):passed = value >= pass_threshold,pass_threshold默认 0.5,值域必须在 [0, 1] 内,越界抛异常; - 返回
Score:原样使用。
同步函数通过asyncio.to_thread执行,异步函数直接 await。另外CodeScorer.digest()会把函数去缩进源码与pass_threshold一起做 sha256,进入环境的env_fingerprint——同一个函数换个阈值判分不同,指纹必须随之改变。
3.3 run_rollouts 与 pass_rate 的统计口径
入口是 runner.py 的同步门:
def run_rollouts( env: Environment, *, k: int = 8, tasks: Optional[Sequence[Task]] = None, model: Optional[Model] = None, concurrency: int = 4, ) -> EnvironmentRunResult:三个 cookbook 脚本统一调用run_rollouts(env, k=6, concurrency=6),即每个任务 6 次尝试、6 路并发——所以 TEST_LOG 中 2 任务 × 6 = 12 次、3 任务 × 6 = 18 次尝试。该函数是arun_rollouts的asyncio.run包装,并附带一条运行约束:不能从已在运行的事件循环里调用(要 await 异步版本)。超时语义上,尝试协程会在env.timeout_seconds(默认 120 秒)时被取消;同步评分器若已在自己的线程里运行则无法被打断,同步门可能阻塞到评分器自然结束。
每个任务的行结果由TaskResult(runner.py)提供:
n_scored:实际被评分的尝试数(超时未完成的尝试不计入);n_passed:其中得分判过的数量;pass_rate:n_passed / n_scored,若n_scored == 0则为None——这就是chained_arithmetic.py里筛选前要判task.pass_rate is not None的原因;- 还有一个"中间带"属性(
0 < n_passed < n_scored),与脚本里手写筛选等价。
results.summary()["tasks"]输出的是含pass_rate的字典列表(basic.py结尾用法);results.task_results则是对象列表(chained_arithmetic.py、recurrence_boundary.py用法),两条访问路径对应同一次运行的两种消费方式。
四、运行方式与适用前提
运行环境要求见 README.md:需要OPENAI_API_KEY,示例命令:
.venvs/demo/bin/python cookbook/environments/_21_math/basic.py .venvs/demo/bin/python cookbook/environments/_21_math/chained_arithmetic.py .venvs/demo/bin/python cookbook/environments/_21_math/recurrence_boundary.py适用前提与限制:
- 脚本硬编码了模型
OpenAIResponses(id="gpt-5.5", reasoning_effort="low", verbosity="low", max_output_tokens=3000)。TEST_LOG 的通过率先验只对这一"模型 + 推理档位 + 2026-07-20 的 agno 版本"组合有效;更换模型或调高reasoning_effort后,各行位置(饱和/中间带/不可解)都会移动,需要重新跑一遍网格; run_rollouts默认k=8、concurrency=4,脚本显式传了k=6, concurrency=6;想扩大样本或换并发,直接改这两个参数即可;- 任务集也可以用 JSONL 加载(
Task.from_jsonl,见 environment.py),每行必须含字符串input,可选expected/id/metadata,出现未知键会直接报错——这是为了防止expected_output之类的列名静默变成expected=None,在容忍 None 的评分器下把一切都判绿。
五、什么时候该用"精确数学"这类任务
README.md 给出的判定标准一句话:当程序化验证器能不借助判断力就裁定对错时,才用精确数学。配套的校准方法论见 cookbook/environments/_07_difficulty_calibration/(本目录方法的出处),同原则在可执行查询场景的对应实现见 cookbook/environments/_22_sql_generation/。
总结这条工作流的复用要点:
- 先用一个明显过易的任务制造饱和行(pass_rate = 1.0),确认评分链路本身正确;
- 通过拉长确定性计算链(数字和、放大、取模、递推轮数)逐行加码,直到
0 < pass_rate < 1; - 用
results.task_results的pass_rate网格读取任务难度位置,只把中间带任务用于评估或数据筛选; - 注意轮数/链长不要过长,否则尝试超时导致
n_scored缩水、行被挤出可评分区间(TEST_LOG 中 11/15 轮任务的教训)。
【免费下载链接】agnoBuild, run, and manage agent platforms.项目地址: https://gitcode.com/GitHub_Trending/ag/agno
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考