模型回你一段"课程已完成前五课",程序没法用——数据库要的是结构化字段;你改了一版提示词,效果变好了吗——"感觉"不算数。本文是「从零手写 Agent」系列第 10 篇,两个工程化硬需求一次讲完:结构化输出让模型的结果可解析,评估让改进可衡量。上一篇的 RAG 让 Agent 查得到资料,这一篇保证它交得出机器能用的结果、且每一次改进都有数字背书。
本文按"结构化输出套路 → 迷你评估框架 → 评估方法论 → 总结"组织,全部数字来自实测。
1、结构化输出:工具提交法
让模型输出受控 JSON,最稳的手写方案借用了系列第 4 篇的机制——最终答案必须通过调用工具提交。给模型唯一一个工具,字段类型由 JSON Schema 声明:
SUBMIT_TOOL = {
"name": "submit_result",
"description": "提交最终结构化结果。完成分析后必须调用本工具提交。",
"input_schema": {
"type": "object",
"properties": {
"course_name": {"type": "string", "description": "课程名"},
"phases": {"type": "number", "description": "阶段数量"},
"done_lessons": {"type": "number", "description": "已完成课数"},
"next_lesson": {"type": "string", "description": "下一课名"},
},
"required": ["course_name", "phases", "done_lessons", "next_lesson"],
},
}
解读:只给这一个工具、系统提示词强调"必须通过工具提交",模型的最终输出就成了带 schema 约束的调用块,从响应里取出 input 即得字段齐全、类型正确的字典。实测用它从课程 README 提取进度,四个字段类型全对。实测返回的结构化结果如下:
{
"course_name": "AgentDev · 从零手写 Agent 学习项目",
"phases": 4,
"done_lessons": 5,
"next_lesson": "L6"
}
解读:phases 与 done_lessons 是真数字而非字符串,必填字段一个不少——这样的结果可以直接 json.dumps 进数据库,接进任何下游程序。这套"以工具调用为输出契约"的思路,正是官方 structured outputs 特性的原理前身:把输出格式从"祈祷模型守规矩"变成"协议强制校验"。
顺带交代为什么不用最"省事"的做法——提示词里贴一份 JSON 模板让模型照抄。那条土路没有任何校验:模型时而给 JSON 套上 markdown 代码围栏,时而擅自加一句寒暄,时而在数字外面补引号;下游解析器每堵一种花式就冒出下一种。工具提交法把格式从社交请求变成接口契约,才算工程方案。
1.1、解析与校验
工具提交法落地要补两个函数:从响应里捞出调用块,以及不信任任何输入的本地校验。实现如下:
def extract_structured(resp) -> dict | None:
for block in resp.content:
if block.type == "tool_use" and block.name == "submit_result":
return block.input # SDK 已保证是 dict
return None
def validate(data: dict, schema: dict) -> list[str]:
errors = []
for field in schema["required"]: # 必填逐项核对
if field not in data:
errors.append(f"缺字段 {field}")
for k, spec in schema["properties"].items():
if spec["type"] == "number" and isinstance(data.get(k), str):
errors.append(f"{k} 应为数字,得到字符串")
return errors
解读:哪怕 schema 声明了类型,接口侧也不保证百分百执行,校验函数是最后一道闸。实测两百次提交里类型填错零次,真正的失败模式是模型绕开工具直接文字作答,出现三次——所以校验之外还得有兜底的纠偏循环,把"没调工具"与"调了但字段不合法"都变成喂回模型的错误数据:
def ask_structured(task: str) -> dict:
history = [{"role": "user", "content": task}]
for _ in range(2): # 最多一次纠偏重试
resp = client.messages.create(
model=MODEL, max_tokens=1024, tools=[SUBMIT_TOOL],
system="完成后必须调用 submit_result 提交。", messages=history)
history.append({"role": "assistant", # 点菜单原样入历史
"content": [b.model_dump() for b in resp.content]})
call = next((b for b in resp.content if b.type == "tool_use"), None)
if call is None: # 没调工具:文字催一次
history.append({"role": "user",
"content": "请调用 submit_result 提交。"})
continue
errs = validate(call.input, SUBMIT_TOOL["input_schema"])
if not errs:
return call.input # 校验通过即收货
history.append({"role": "user", "content": [{ # 报错回传
"type": "tool_result", "tool_use_id": call.id,
"is_error": True, "content": ";".join(errs)}]})
raise RuntimeError("两轮未产出合法结果")
解读:这套"提交、校验、报错、重试"与第 4 篇"错误也是数据"一脉相承——校验失败不是异常,是纠偏素材。实测三次绕开工具的运行全部被第一轮催回;两轮上限是防打转的安全阀,与第 5 篇的 max_turns 同一个思想。
1.2、官方语法约束特性
官方后来把这件事做成了正式特性:beta 阶段叫 output_format 参数(要带 beta 请求头),截至本文写作已转正为 output_config.format,把 JSON Schema 编译成语法约束,解码时直接禁止不合语法的 token——不是"答完再查",是"答的时候就只能这么答"。原理上仍是工具提交法的那套契约,只是执行点从你的校验代码前移到了模型的解码器。两条路线对比如下:
| 维度 | 工具提交法 | 官方结构化输出 |
|---|---|---|
| 约束时机 | 生成后校验,失败重试 | 解码时语法强制 |
| 适用端点 | 任何支持工具调用的接口 | 需新版接口参数 |
| 额外成本 | 可能多一次往返 | schema 编译开销 |
| 表达力 | 与工具参数同构 | 递归 schema 不支持 |
解读:手写阶段建议两种都留。工具提交法不挑端点,且与 Agent 循环天然咬合——工具调用本来就是循环的一等公民;官方特性适合一次性抽取场景,一条请求出去就是保证合法的 JSON。两者也能组合:官方特性保证形状合法,本地校验再补业务规则——数字范围、枚举取值这类 schema 表达不了的约束。细节以官方 structured outputs 文档为准。
2、迷你评估框架
评估三板斧:测试集、跑分、对比。一个不到四十行的评估循环如下:
TEST_CASES = [
("计算 1234 + 567", 1801),
("999 除以 3 是多少", 333),
("计算 0.1 + 0.2", 0.3), # 浮点精度陷阱
]
def run_eval() -> int:
correct = 0
for question, expected in TEST_CASES:
agent = Agent(tools_spec=CALC_SPEC, tool_impls={"calculate": calc},
system="所有计算必须用工具,最终答案只输出数字。",
max_turns=4, verbose=False)
got = extract_number(agent.run(question)) # 宽松抽取首个数字
correct += abs(got - expected) < 1e-6 # 数值容差判分
return correct
解读:每个用例起一个干净 Agent 避免相互污染;判分函数按场景选宽松度,分类题用精确匹配、计算题用数值容差、开放题可上模型当裁判。八道算术题的实测结果是八比八,连 0.1 加 0.2 的浮点陷阱也被容差覆盖——评估器本身的精度设计,是被很多团队忽略的必修课。
2.1、判分函数三档宽松度
判分器是评估的地基。上面那行 extract_number 与三档判分函数的完整实现如下:
import re
def extract_number(text: str) -> float:
m = re.search(r"-?\d+(?:\.\d+)?", text) # 宽松抽取首个数字
if not m:
raise ValueError("回答里找不到数字")
return float(m.group())
def judge_exact(got, expected): # 一档:精确匹配
return str(got).strip() == str(expected)
def judge_number(got, expected, tol=1e-6): # 二档:数值容差
return abs(float(got) - float(expected)) < tol
def judge_contains(got, expected): # 三档:关键词在场
return str(expected) in str(got)
解读:三档从紧到松,选错档会得出相反的结论——太紧把对的判错(0.3 对 0.30000000004),太松把错的判对(抽取器捞到题干里的数字)。判分器写错是最隐蔽的 bug:它不报错,只是安静地把每次改进判成无效、或把每次退步判成进步。上线前先拿三组已知对错的标准答案,把判分器本身测一遍。
测试集从哪来也别凭空造。第一版从自己的真实翻车记录里攒:每次 Agent 答错而你知道对错的案例,原样收进用例库;跑通一轮后再补边界题与陷阱题。八道题里三道来自真实翻车,比三十道拍脑袋编的题有用——题目立场要站在系统的对立面,专挑它不会的地方问。
2.2、对比闭环的实现
评估的价值在对比:同一测试集、两个配置各跑一遍看差值。把开头的 run_eval 参数化成 run_suite,基线对比脚本如下:
def run_suite(system: str) -> int:
correct = 0
for question, expected in TEST_CASES:
agent = Agent(tools_spec=CALC_SPEC,
tool_impls={"calculate": calc},
system=system, max_turns=4, verbose=False)
try:
got = extract_number(agent.run(question))
except ValueError:
continue # 抽不到数字记零分
correct += judge_number(got, expected)
return correct
base = run_suite("所有计算必须用工具,最终答案只输出数字。")
edit = run_suite("所有计算必须用工具。") # 删掉约束后半句
print(f"基线 {base}/{len(TEST_CASES)} → 改动后 {edit}/{len(TEST_CASES)}")
解读:脚本的输出就是结论本身,实测记录如下:
基线 8/8 → 改动后 6/8
失败两题:999 除以 3、0.1 加 0.2
失败原因:回答混入解释文字,抽取器捞到题干里的 999 与 0.1
解读:失败机理值得细品——不是模型算错了,是它开始说人话,而抽取器只认第一个数字。改提示词伤到的是提示词与判分器之间的隐性契约,这正是下一节"改动要走对比闭环"的实证:删一句、跑一遍、比一次,改动的影响立刻现形,靠肉眼读八段回答永远看不出这一层。
3、评估方法论
框架易写,方法论难守,四条铁律来自大量翻车总结。
第一,单次跑分是玄学:模型输出非确定,同一个八题测试集连跑三遍,分数会在七与八之间跳动;正式结论必须多轮取均值,最好附上波动区间。第二,改动要走对比闭环:删掉提示词里"只输出数字"一句,重跑发现回答里混入废话导致抽取失败——改一行、跑一遍、比一次,三个动作缺一不可。
第三,测试集质量决定评估上限:题目要覆盖边界与陷阱,八道全对只说明"这八道会",不说明强。第四,别让答案泄漏进提示词:把期望输出写进系统提示词"帮"模型,测出来的是复读能力。当这些成为肌肉记忆,提示词工程就从手艺变成了实验科学。
3.1、多轮跑分的口径
"多轮取均值"具体怎么取,实测给个基准:八题测试集连跑五遍,得分依次为 8、7、8、7、8,均值 7.6,波动区间 7 到 8。报告写法是 7.6 ± 0.5(5 轮),均值配区间,别单写一个最大值。跑多少轮合适?经验口径是十轮以内收益明显,再往上均值基本收敛,多跑只是烧钱;两个配置的差值小于波动区间时,只能判"无显著差异",硬解读就是自欺。成本上不用心疼:八题一轮约几千 token,五轮几万 token,几分钱级别——真正贵的是测试集本身的建设。
还有一条分层原则呼应上一篇:检索型 Agent 别只看端到端答案,检索层命中率单独测——五十个问题人工标好应命中的块,检索对不对一眼可判。端到端分数掉了,先分层定位是检索退步还是生成退步,两种修复的成本差一个量级。
4、把两件事接起来
结构化输出与评估是天然搭档:Agent 的产出结构化之后,判分才能自动化;判分自动化之后,提示词、工具描述、模型选型的每次改动才能走"改、测、比"的流水线。本系列前九篇的每个设计决策——工具描述的措辞、压缩参数、检索条数——严格说都应该跑在这个流水线上验收,这也是把评估放在框架对比篇之前的原因:先有标尺,再谈框架谁优谁劣。
串成流水线只比第 2 节多一步:Agent 的最终答案改用 submit_result 提交,判分函数从正则抽取数字换成直接读结构化字段。2.2 节那个"抽取器捞到题干数字"的隐性契约问题从根上消失——判分器不再依赖回答的措辞,只依赖字段的值。这两步合起来,就是"评测驱动开发"的最小可用版:任何改动提交前,先过一遍自己的跑分。
5、总结
结构化输出的手写套路是"唯一工具加 schema 契约",评估的最小闭环是"测试集加自动判分加前后对比";单次跑分不算数、改动必须对比验收、测试集质量是评估上限。对于正在调提示词的同学,建议先花一小时攒十道题的测试集,此后每次改动都跑分记录——一周后你会拥有自己 Agent 的第一条性能曲线。下一篇进入框架的世界:官方 Agent SDK 与手写版逐项对账。评估方法可参考 Anthropic 评估文档。

