团队内部分享 · yama / 阎魔 —— 判定 Agent 行为的那位

yama

Agent Skill 的单元测试框架

一份 YAML 描述模型看到的一切与它该做到的一切;yama 负责调用 LLM、驱动 Tool Loop、落盘全部证据,最后由确定性硬检查LLM Judge 落判。

声明式 YAML Case LiteLLM 多 Provider Tool Mock / bash 沙箱 HTML 报告 pip install python-yama
壹 · 思路

Skill 是 Agent 时代的「代码」,
代码就需要单元测试

Agent 应用的业务逻辑正在从 if/else 迁移进 SYSTEM.md、SKILL.md 和 Tool Schema——改一句 prompt 就是改一行代码。但这套「代码」没有编译器、没有类型检查, 运行时还是概率性的:同一个输入,两次运行可能给出不同输出。传统软件工程里 兜底的那张网,在这里是缺位的。

传统软件Agent 应用差异
源代码SYSTEM.md / SKILL.md / Tool Schema自然语言写成,无编译期检查
运行时LLM + Tool Loop概率采样,非确定性执行
依赖注入 / MockTool Mock工具返回决定模型后续全部行为
单元测试yama 补的就是这个空位

先有 Benchmark,才谈得上优化

市面上的 Skill 优化工具很多——自动改写 prompt、A/B 换模型、遗传算法调 SKILL.md…… 但所有优化的前提是同一个:先明确优化目标是什么、怎么度量。 没有可重复运行的 Benchmark,「优化后更好了」只是一种感觉。 yama 不做优化本身,它提供的是优化的地基:把「这个 Plugin 应该表现成什么样」 写成可反复执行、可判定通过与否的用例集。

什么叫「更好」:四把尺子,按序落判

有了 Benchmark,「模型 A 换成 B 值不值」「Plugin 这版改完有没有变好」就变成 同一批 Case 上的四个数字——而且比较有先后次序:先比对错,再比好坏, 最后才轮到成本与速度

第一 · 对错

硬检查通过率高

该调的工具调了、不该调的没调、关键实参正确。这是门槛而非分数:任一硬检查失败,Case 直接判负,后面三把尺子不再看。

pass_rate · hard-checks.json
第二 · 好坏

Judge 得分高

多维 LLM Judge 的 overall(0–1)与各维度分数;报告逐 Step 展开的 transcript 与评分理由,同时就是人工复核(Human Judge)的证据链。

judge overall / dimensions
第三 · 成本

Token 消耗少

通过率与分数打平时,谁更省谁赢。usage 从单次请求逐层聚合到 Step、Run、Case,报告每一层都标着 token 数。

usage · in / out tokens
第四 · 速度

耗时短

响应快慢直接决定体验。yama 目前记录每次工具调用的耗时(duration_ms),整场 run 的墙钟耗时先用终端 / CI 计时衡量。

duration_ms · mock-events.json
这套次序就写在 yama 里。报告为多个 run 评选最优(🏆)用的正是同一份排序: 是否通过 → Steps / 硬检查通过数 → Judge overall → token 更少者优。 要对比两个模型、或一个 Plugin 的前后两版,就用 repeat 运行矩阵让它们在 同一批 Case 上对跑,报告按 label 分组给出通过率、Judge 均值与 tokens(做法见「肆」)。

yama 回答四个问题

DEFINE

如何定义用例

一个 Case 一份 YAML:模型看到的 Context(System Prompt / Skill / 工具)、依次发送的用户回合、每一步的断言。声明式,进 git,可 review。

MOCK

如何 Mock

Tool Schema 发给模型,返回值由 Mock 决定:声明式 respond、Python Handler、或内置 bash 沙箱。工具世界完全可控、可复现。

RUN

如何执行

无 Runtime 抽象:直接构造请求调 LLM,驱动多步 Tool Loop。每次发出的请求、每个工具调用、每步对话快照全部落盘。

JUDGE

如何判定与 Debug

协议问题用九种确定性硬检查;语言质量用多维 LLM Judge 打分。HTML 报告逐 Step 展开 transcript 与断言证据。

一次 Case 执行的路径

两条关键的设计原则。 ① Schema 与执行分离:context.tools 是模型看到的接口,mocks.tools 是 Runner 怎么回答,两边名字集合必须完全相等; ② 硬检查与质量评分分离:协议错误(该调的工具没调)用确定性断言一票否决, Judge 分数再高也救不回来——先讲对错,再谈好坏。
贰 · 编写 Case

一份 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 }
① context模型可见的世界:System Prompt、按需查阅的 Skill、Tool Schema、历史消息,甚至图片/音频 fixture。全部显式声明,全部可复现。
② mocks工具被调用时返回什么。支持固定 result、按次序的 sequence、按参数分支的 match,以及 Python Handler 与共享 state
③ steps一步一个用户回合。当前步的 Tool Loop 收敛出「无工具调用的最终回复」后才发下一条,断言只作用于当前 Step。
④ outcome任一硬检查失败 = Case 必失败(不可关闭);Judge 再加 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 出现次数达标(注意:纯子串计数,无语义理解)
pythonStepResult / 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.toolsmocks.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 内唯一的 iduser;断言默认只看当前 Step,跨 Step 检查用 Python Checker。
叁 · CLI 用法

安装、运行、看报告

PyPI 发行名是 python-yamayama 和 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 NCase 级并行上限(默认 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 里直接拿退出码做门禁。
肆 · 评测一个 Plugin

从「感觉还行」到「6 个用例、通过率 83%」

评测一个现有 Plugin 的完整走法。前提只有目录约定:Plugin 根目录有 SYSTEM.md,Skill 放 skills/<name>/SKILL.md, 评测资产集中在 __evals__/ 下。

  1. 盘点 Plugin 的行为面

    读 SYSTEM.md 和每个 SKILL.md,列出它承诺的关键行为:什么时候必须查 Skill、什么时候必须调哪个工具、什么动作必须先向用户确认。每一条承诺就是一个候选 Case。

  2. 准备 Tool Schema 与 Mock

    把生产 Tool Schema 原样存成 __evals__/tools/*.yaml 供多个 Case 复用;为每个工具写最简单的 respond.result。需要按参数分支或维护状态时再上 match / Python Handler。

  3. 先写最关键的 Case:happy path + 高频翻车点

    不求覆盖率,先把「必须对」的两三条路径和线上出过事故的场景写下来。断言从最硬的开始:该调的工具(tool_called)、不该调的工具(tool_not_called)、关键实参(tool_arguments)。

  4. 用 preview 校对,再真跑

    yama --preview(或 dashboard 的 /preview,改 YAML 刷新即生效)零成本确认模型将看到的消息、Mock 与断言都符合预期,然后 yama --report 真跑。

  5. 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
  6. 失败了:先分层,再读产物

    按下表定位失败层级,再进产物目录或 HTML 报告逐 Step 看证据。

失败分层速查

现象层级去哪看
stderr 打印 Collection error,退出码 2用例根本没跑:YAML / Schema / 路径错误错误消息本身,对照「硬规则」
stderr 打印 LLM error,退出码 2Provider 配置缺失等 LLM 层错误错误消息;已写出的产物目录
退出码 1,某 Run 标红带 errorrun 级基础设施失败:未 Mock 的工具被调用、sequence 用尽、Step 超时、Handler 抛异常result.jsonruns[].error
退出码 1,某 Step 硬检查失败模型行为不符合预期,或断言写错终端展开的 expected / actual;hard-checks.json
硬检查全过但 Case 失败Judge 不达标judge-result.json 各维度 score / reason / evidence

高频误判:先分清「模型错了」还是「用例错了」

  • mock-events.jsonmatch_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 不在模型可见内容里。
伍 · 测试驱动开发 Plugin

红 → 绿 → 稳 → 护:
用 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:先锁行为,再动手

  1. 给现状写「表征用例」

    不改任何 prompt,先把当前实际行为——包括你想改掉的行为——写成能通过的 Case。这是重构前的安全网:改动之后哪些行为变了,一目了然。

  2. 把线上翻车对话提炼成 Case

    每次 badcase 复盘的产出不是一段结论,而是一个新 Case:复现当时的 Context 与用户输入,断言正确行为。修复后它永远守着这个场景。

  3. 大改动用运行矩阵对比

    换模型、重写 SKILL.md 这类大动作,用 repeat 矩阵让新旧配置在同一批 Case 上对跑,报告按 label 分组对比通过率、Judge 分与 token 消耗——用数据说话,不靠对话框里的感觉。

配套 Claude Skill:仓库里的 skills/eval-plugin-with-yama 让 Claude 直接帮你写 Case、配 Mock、排查失败——把这套流程接进日常的 Agent 辅助开发里。写用例这件事本身,也可以是 Agent 干的。