Skill 是 Agent 时代的「代码」,
代码就需要单元测试
Agent 应用的业务逻辑正在从 if/else 迁移进 SYSTEM.md、SKILL.md 和 Tool Schema——改一句 prompt 就是改一行代码。但这套「代码」没有编译器、没有类型检查, 运行时还是概率性的:同一个输入,两次运行可能给出不同输出。传统软件工程里 兜底的那张网,在这里是缺位的。
| 传统软件 | Agent 应用 | 差异 |
|---|---|---|
| 源代码 | SYSTEM.md / SKILL.md / Tool Schema | 自然语言写成,无编译期检查 |
| 运行时 | LLM + Tool Loop | 概率采样,非确定性执行 |
| 依赖注入 / Mock | Tool Mock | 工具返回决定模型后续全部行为 |
| 单元测试 | ? | yama 补的就是这个空位 |
先有 Benchmark,才谈得上优化
市面上的 Skill 优化工具很多——自动改写 prompt、A/B 换模型、遗传算法调 SKILL.md…… 但所有优化的前提是同一个:先明确优化目标是什么、怎么度量。 没有可重复运行的 Benchmark,「优化后更好了」只是一种感觉。 yama 不做优化本身,它提供的是优化的地基:把「这个 Plugin 应该表现成什么样」 写成可反复执行、可判定通过与否的用例集。
什么叫「更好」:四把尺子,按序落判
有了 Benchmark,「模型 A 换成 B 值不值」「Plugin 这版改完有没有变好」就变成 同一批 Case 上的四个数字——而且比较有先后次序:先比对错,再比好坏, 最后才轮到成本与速度。
硬检查通过率高
该调的工具调了、不该调的没调、关键实参正确。这是门槛而非分数:任一硬检查失败,Case 直接判负,后面三把尺子不再看。
Judge 得分高
多维 LLM Judge 的 overall(0–1)与各维度分数;报告逐 Step 展开的 transcript 与评分理由,同时就是人工复核(Human Judge)的证据链。
Token 消耗少
通过率与分数打平时,谁更省谁赢。usage 从单次请求逐层聚合到 Step、Run、Case,报告每一层都标着 token 数。
耗时短
响应快慢直接决定体验。yama 目前记录每次工具调用的耗时(duration_ms),整场 run 的墙钟耗时先用终端 / CI 计时衡量。
yama 回答四个问题
如何定义用例
一个 Case 一份 YAML:模型看到的 Context(System Prompt / Skill / 工具)、依次发送的用户回合、每一步的断言。声明式,进 git,可 review。
如何 Mock
Tool Schema 发给模型,返回值由 Mock 决定:声明式 respond、Python Handler、或内置 bash 沙箱。工具世界完全可控、可复现。
如何执行
无 Runtime 抽象:直接构造请求调 LLM,驱动多步 Tool Loop。每次发出的请求、每个工具调用、每步对话快照全部落盘。
如何判定与 Debug
协议问题用九种确定性硬检查;语言质量用多维 LLM Judge 打分。HTML 报告逐 Step 展开 transcript 与断言证据。
一次 Case 执行的路径
context.tools 是模型看到的接口,mocks.tools
是 Runner 怎么回答,两边名字集合必须完全相等;
② 硬检查与质量评分分离:协议错误(该调的工具没调)用确定性断言一票否决,
Judge 分数再高也救不回来——先讲对错,再谈好坏。
一份 YAML,四段结构
Case 文件相对 Plugin Root 的路径就是它的身份(case key),不需要在文件里声明
id。所有相对路径(Tool Schema、Handler、fixture)一律相对
__evals__/ 目录解析。
# __evals__/cases/plan/first-directions.yaml description: 模型应先读 DSL 再给出差异化的创意方向 model: { name: gpt-5, temperature: 0 } # 可省略,继承 yama.toml context: # ① 模型看到什么 system_prompt: { default: true } # Plugin 根目录的 SYSTEM.md skills: - plan-visual-style # skills/plan-visual-style/SKILL.md tools: - builtin: skill # skills 非空时必须声明 - file: tools/read-dsl.yaml # 生产 Tool Schema,直接复用 mocks: # ② Tool Call 如何被执行 tools: read_dsl: respond: result: visualStyle: { name: 复古胶片 } steps: # ③ 依次发送的用户回合 - id: request-directions user: 给我几个创意方向 assert: hard: # 确定性硬检查 - tool_called: { name: read_dsl } - assistant_contains: 创意方向 judge: # LLM 质量评分 dimensions: - name: differentiation criteria: 几个方向是否具有明显差异 min_score: 4 outcome: # ④ Case 级通过门槛 require: judge: { overall_gte: 0.8 }
result、按次序的 sequence、按参数分支的 match,以及 Python Handler 与共享 state。overall_gte / each_dimension_gte 的整体门槛。九种硬检查
| 类型 | 检查对象 | 通过条件 |
|---|---|---|
tool_called | 当前 Step 的 Tool Calls | 调用次数满足 times / min_times / max_times(默认至少 1 次) |
tool_not_called | 当前 Step 的 Tool Calls | 指定工具调用次数为 0 |
tool_arguments | 某次调用的实参 | JSONPath 选中的值满足 matcher(equals / contains / matches / is_instance / exists) |
tool_call_order | 调用顺序 | names 有序子序列,或 groups 分组先后 |
assistant_contains | 最终回复文本 | 包含指定字面文本 |
assistant_not_contains | 最终回复文本 | 不包含指定字面文本 |
assistant_matches | 最终回复文本 | 正则 re.search 命中 |
assistant_semantic_count | 最终回复文本 | 字面 marker 出现次数达标(注意:纯子串计数,无语义理解) |
python | StepResult / Transcript / Mock state | 自定义 Checker 返回通过——DSL 校验、跨 Step 业务不变量放这里 |
Judge:把「好不好」变成可度量的维度
每个维度 1–5 打分并给出 reason 与 evidence,归一化后按权重加权得到
overall(0–1)。默认只在硬检查全过后才评分;Judge 分数永远不能抵消硬检查失败。
评分模型可以与被测模型不同(judge.model)。
judge: model: judge-model # 省略则用当前 Case 的 model dimensions: - name: instruction_following criteria: 是否满足用户明确要求 weight: 0.4 min_score: 4 # 单维度硬下限 - name: communication criteria: 回复是否清晰、简洁并提供可执行动作 weight: 0.6
写 Case 前必须知道的硬规则
context.tools与mocks.tools的工具名集合必须完全相等(双向,不是单向包含);builtin 工具未自定义 Mock 时由框架自动补齐。context.skills非空时必须声明{builtin: skill}——Skill 正文只能靠模型主动调用 skill 工具读到,System Prompt 里只有 name/description 目录。这保证每个 Case 都在测真实的「按需查阅」行为。- 模拟命令行工具用
{builtin: bash}:mocks.cli按命令树返回确定性输出;mocks.fs在隔离沙箱里跑真实 shell。二者互斥,一个 Case 只能选一种。 - Mock 与断言分工明确:Mock 只回答「被调用时返回什么」;「必须 / 不能被调用」永远用
tool_called/tool_not_called断言表达。 respond.sequence消费完再被调用是整条 run 立即失败(不是重复最后一条);未 Mock 的工具被调用同样是硬失败。- 每个 Step 必须有 Case 内唯一的
id和user;断言默认只看当前 Step,跨 Step 检查用 Python Checker。
安装、运行、看报告
PyPI 发行名是 python-yama(yama 和 yaml 太像被拒了),
命令行入口仍叫 yama,要求 Python ≥ 3.12。API Key 可以放
.env,CLI 启动时自动向上查找加载。
# 安装为全局命令行工具 $ uv tool install python-yama # 或 pipx / pip install python-yama # 在 Plugin 根目录直接跑(默认收集 __evals__/cases/**/*.yaml) $ cd dingding-simple && yama # 在 Workspace 根目录(有 yama.toml)按名字选 Plugin,并生成 HTML 报告 $ yama --plugin dingding-simple --report # 只跑一个文件;或按 case key 筛选(子串匹配,含 * ? [ 时按通配符) $ yama __evals__/cases/plan/first-directions.yaml $ yama -k smoke -k "__evals__/cases/plan/*.yaml" # 不调 LLM,只解析 Case 生成用例预览(写 Case / Review 时的回路) $ yama --preview # 本地 dashboard:首页列历史报告,/preview 每次刷新重新解析 Case $ yama dashboard # http://127.0.0.1:8765/
常用参数
| 参数 | 作用 |
|---|---|
--plugin NAME / --all-plugins / --plugin-root PATH | 三种 Plugin 选择方式,互斥;都不给时向上找 yama.toml,找不到就把当前目录当 Plugin Root |
-k, --filter PATTERN | 按 case key 筛选,可重复,命中任意一个即运行 |
--report [PATH] | 生成单文件 HTML 报告;省略路径时写 .yama/reports/report-<时间戳>.html(历史累积),latest.html 软链指向最新一份 |
--preview | 零 LLM 调用,解析 Case 输出预览 HTML(配 --json 输出 JSON) |
--concurrency N | Case 级并行上限(默认 10);1 时逐 Step 流式输出,适合盯单个用例 |
--list / --json / --no-artifacts / --result-dir | 只列用例 / 机器可读输出 / 不写产物 / 覆盖产物目录 |
每次 run 的全部证据都在产物目录
<plugin_root>/.yama/runs/<case_key>/run-001/ ├── result.json # run 是否通过、error / traceback ← 先看这个 ├── hard-checks.json # 每条断言的 expected / actual / reason(不短路,全记录) ├── mock-events.json # 每次工具调用的实参 / 返回 / match_failed / state 变化 ├── requests/request-00N.json # 每次真实发给 LLM 的完整请求(messages + tools) ├── transcript.json # 每个 Step 结束后的完整对话快照 ├── rendered-system-prompt.txt # 模型实际看到的 System Prompt └── case.resolved.yaml # 展开后的 Case(所有默认值、Skill、Schema 落定)
0 全部通过 · 1 有 Case 失败 ·
2 Collection error / LLM error / 没收集到 Case · 130 手动中断。
CI 里直接拿退出码做门禁。
从「感觉还行」到「6 个用例、通过率 83%」
评测一个现有 Plugin 的完整走法。前提只有目录约定:Plugin 根目录有
SYSTEM.md,Skill 放 skills/<name>/SKILL.md,
评测资产集中在 __evals__/ 下。
-
盘点 Plugin 的行为面
读 SYSTEM.md 和每个 SKILL.md,列出它承诺的关键行为:什么时候必须查 Skill、什么时候必须调哪个工具、什么动作必须先向用户确认。每一条承诺就是一个候选 Case。
-
准备 Tool Schema 与 Mock
把生产 Tool Schema 原样存成
__evals__/tools/*.yaml供多个 Case 复用;为每个工具写最简单的respond.result。需要按参数分支或维护状态时再上match/ Python Handler。 -
先写最关键的 Case:happy path + 高频翻车点
不求覆盖率,先把「必须对」的两三条路径和线上出过事故的场景写下来。断言从最硬的开始:该调的工具(
tool_called)、不该调的工具(tool_not_called)、关键实参(tool_arguments)。 -
用 preview 校对,再真跑
yama --preview(或 dashboard 的/preview,改 YAML 刷新即生效)零成本确认模型将看到的消息、Mock 与断言都符合预期,然后yama --report真跑。 -
repeat 多次,看的是通过率不是单次结果
概率性系统里跑一次过了不算过。
execution.repeat让同一 Case 跑 N 次统计稳定性;写成运行矩阵还能同 Case 对比多个模型——比的正是「壹」里那四把尺子:通过率、Judge 均值、tokens,报告自动按 label 分组、给最优 run 标 🏆。# yama.toml — 同一批 Case,两个模型各跑 2 次对比 [[defaults.execution.repeat]] model = { name = "gpt-5" } times = 2 [[defaults.execution.repeat]] label = "sonnet" model = { name = "anthropic/claude-sonnet-5" } times = 2
-
失败了:先分层,再读产物
按下表定位失败层级,再进产物目录或 HTML 报告逐 Step 看证据。
失败分层速查
| 现象 | 层级 | 去哪看 |
|---|---|---|
stderr 打印 Collection error,退出码 2 | 用例根本没跑:YAML / Schema / 路径错误 | 错误消息本身,对照「硬规则」 |
stderr 打印 LLM error,退出码 2 | Provider 配置缺失等 LLM 层错误 | 错误消息;已写出的产物目录 |
| 退出码 1,某 Run 标红带 error | run 级基础设施失败:未 Mock 的工具被调用、sequence 用尽、Step 超时、Handler 抛异常 | result.json 的 runs[].error |
| 退出码 1,某 Step 硬检查失败 | 模型行为不符合预期,或断言写错 | 终端展开的 expected / actual;hard-checks.json |
| 硬检查全过但 Case 失败 | Judge 不达标 | judge-result.json 各维度 score / reason / evidence |
高频误判:先分清「模型错了」还是「用例错了」
mock-events.json里match_failed: true→ 实参没匹配上 Mock 的match.arguments,模型收到统一的 UNKNOWN_ERROR,后续行为全被带偏。先判断是匹配条件写太死,还是模型真传错了参数。tool_called失败但 transcript 里明明调用了 → 断言只作用于当前 Step,调用发生在别的 Step 不算。- bash 返回
command not found(exit 127)→ 命令没在mocks.cli里声明、用绝对路径绕过了 PATH shadow、或模型拼错了命令名。 - 模型「看不到」命令失败 → 发给模型的只有 stdout + stderr 拼接文本,exit code 不在模型可见内容里。
红 → 绿 → 稳 → 护:
用 Case 驱动 Prompt 工程
经典 TDD 的红绿循环搬到 Prompt 工程上同样成立——只是多了一步: 概率性系统里「过一次」不等于「过了」,绿之后还要看稳定性。
先写 Case,让它失败
动 SYSTEM.md 之前,把期望行为写成 Case。用 --preview 校对 Context,跑一次确认它因为对的理由失败——断言真的在测你想测的行为。
改 Prompt,让它通过
迭代 SYSTEM.md / SKILL.md / 工具描述,直到 Case 通过。报告里的 rendered-system-prompt.txt 和逐 Step transcript 告诉你模型到底看到了什么、卡在哪一步。
repeat N 次,看通过率
execution.repeat 跑出稳定率:10 次过 6 次说明 prompt 还在靠运气。把「差异化」「简洁度」这类软目标写成 Judge 维度——评分标准本身就是优化方向。
沉淀为回归集
Case 进 git,每次改 prompt 全量重跑。Prompt 工程最大的隐患是「改好这句、改坏那句」,回归集是唯一的防线;退出码直接接 CI 门禁。
迭代旧 Plugin:先锁行为,再动手
-
给现状写「表征用例」
不改任何 prompt,先把当前实际行为——包括你想改掉的行为——写成能通过的 Case。这是重构前的安全网:改动之后哪些行为变了,一目了然。
-
把线上翻车对话提炼成 Case
每次 badcase 复盘的产出不是一段结论,而是一个新 Case:复现当时的 Context 与用户输入,断言正确行为。修复后它永远守着这个场景。
-
大改动用运行矩阵对比
换模型、重写 SKILL.md 这类大动作,用 repeat 矩阵让新旧配置在同一批 Case 上对跑,报告按 label 分组对比通过率、Judge 分与 token 消耗——用数据说话,不靠对话框里的感觉。
skills/eval-plugin-with-yama
让 Claude 直接帮你写 Case、配 Mock、排查失败——把这套流程接进日常的
Agent 辅助开发里。写用例这件事本身,也可以是 Agent 干的。