Skip to content
Charles Shao
Go back

Agent 工具设计:schema、参数校验与写操作防护

创建于:
v1.0.0
–views

上一篇实现了调度循环:模型请求调用工具,代码执行并把结果回灌到 messages,直到满足某个出口。循环本身没有多少可调的地方,同一个循环接上不同的工具,效果差别主要来自工具的 schema。

模型看不到工具的 Python 实现,只能看到 tools 参数里的那段 JSON。schema 写得含糊,模型就只能猜;参数约束写得宽松,模型就可能填入不合法的值。本文继续不使用框架,沿用上一篇的循环,把任务从「查报表」推进到「追投」。追投拆成两个工具:

工具作用读写
search_adgroups按计划和追投开始日期,返回可追投的广告组及各档位日预算只读
boost_adgroup为指定广告组下追投单,提交即从账户余额预付扣款写操作

模拟数据中,计划「双11-美妆拉新」下有三个广告组。只有已过学习期的 AG-002 支持 aggressive 档:

adgroup_id名称学习期可用档位日预算(元)
AG-001直播间引流-新客是conservative、standard180、260
AG-002商品卡成交-老客否conservative、standard、aggressive150、220、400
AG-003短视频种草-兴趣人群是conservative110

本文用到的术语如下,其中回灌、终止与前两篇的定义一致:

术语含义
schema通过 tools 参数提供给模型的工具描述,JSON 格式
回灌把工具的返回值或错误信息作为 role=tool 消息追加到 messages
终止代码直接结束循环,不再回灌,也不再请求模型
adgroup_id 白名单本次 run 中 search_adgroups 实际返回过的 ID 集合,由代码维护,不提供给模型
确认写操作执行前,先把方案告知用户,收到用户的确认消息后才执行

Table of contents

Open Table of contents

一、模型能看到什么

每次请求时,tools 和 messages 一起发送给模型。模型每一轮都依据这份 schema 决定是否调用工具、调用哪一个、参数怎么填。对它的决策起作用的是以下几个字段:

字段模型的用途写得不好的后果
name标识工具,调用时原样返回命名含糊(如 do_it、query),模型难以选对工具
description判断这个工具做什么、此刻该不该用缺少前置条件,模型会跳过必要的查询,直接执行后续操作
parameters.properties确定参数名、类型和含义参数缺少说明或取值范围,模型只能根据上下文推测
required / enum / additionalProperties限定必填项和合法取值约束越宽松,模型填错或编造参数的空间越大

工具在数组里的先后顺序不是表达依赖关系的可靠手段。即使把 boost_adgroup 排在 search_adgroups 前面,只要 description 写清了「先搜索再追投」,模型通常仍会先搜索;反过来,description 没写清,调整顺序也解决不了问题。

左侧是模型看到的四类信息:name 用于标识工具,description 用于判断此刻该不该调用,properties 说明每个参数填什么,required 和 enum 限定取值。右侧是执行前的三层检查:解析层检查 arguments 是否为合法 JSON,不合法时回灌原文;校验层检查缺字段、取值不在 enum、日期格式错误,回灌期望的格式;执行层处理档位不支持、余额不足等业务错误并回灌,鉴权失败和代码 bug 则终止。写操作还要检查 adgroup_id 白名单并等待确认。

上一篇查询报表时,模型之所以知道要先调用 search_campaign,依据的是 get_campaign_report 描述里的第二句:

description=(
    "按 campaign_id 和日期查询计划日报表(ROI、消耗、成交额等)。"
    "没有 campaign_id 时,先调用 search_campaign。"
    "报表 T+1 产出,只能查询昨天及更早的日期。"
)

去掉第二句,模型可能直接编一个形如 CP-xxxx 的 ID 去查报表。如果这个 ID 恰好对应账户里的另一个计划,接口不会报错,模型会用别的计划的数据给出一个看似正常的回答。

二、description:写清职责、调用时机与参数来源

description 的读者是模型,不是开发者。模型看不到源码,也不了解团队内部的约定,只能依据当前对话和这段文字做判断。因此每个工具的 description 至少要写清三件事:

  1. 职责。 用一句话说明工具做什么、返回什么。「处理广告投放相关事务」这类描述无法帮助模型区分工具。
  2. 调用时机。 缺少哪些信息时要先调用其他工具,哪些情况下不应调用。例如用户只问「能追投多少」「要花多少钱」时,只能查询,不能下单。
  3. 参数来源。 关键参数应该从哪里取得。例如 adgroup_id 只能来自 search_adgroups 的返回结果,不能用广告组名称代替,也不能自行构造。

按这三点,追投的两个工具描述如下(取自源码 tools/boost.py 的 create_boost_tools,parameters 见第三节):

search_adgroups_tool = Tool(
    name="search_adgroups",
    description=(
        "按 campaign_id 和追投开始日期,搜索计划下可追投的广告组,返回各组可用档位和日预算。"
        "只有计划、还没有 adgroup_id 时,先调用此工具。"
        "此工具只查询,不会下单。"
    ),
    parameters={...},
    fn=session.search_adgroups,
)
boost_adgroup_tool = Tool(
    name="boost_adgroup",
    description=(
        "为广告组下追投单,提交即从账户余额预付扣款。"
        "adgroup_id 必须来自本次 search_adgroups 的返回结果,没有时先搜索,不要自行构造。"
        "只在用户明确要求追投时调用;用户只询问预算或可用档位时不要调用。"
        "每次只为一个广告组下单,一个方案确认并执行后再提交下一个。"
        "用户已要求追投时直接调用即可:首次调用不会扣款,只返回待确认的方案,须向用户说明并等待确认。"
    ),
    parameters={...},
    fn=session.boost_adgroup,
)

fn 指向 session 的方法,而不是模块级函数。白名单和待确认的方案属于某一次 run,第六节会说明 session 的作用。

boost_adgroup 描述的最后一句来自一次实测。最初的描述只写了「首次调用会返回待确认的方案」,模型查完报表和广告组后没有调用 boost_adgroup,而是先用文字询问用户是否追投;用户回复后,模型才调用工具,代码又会要求确认一次。因此描述里补充了「首次调用不会扣款」。模型对调用代价的判断同样来自 description:只写「扣款」,模型就可能回避调用。

实践中有两种常见的写法问题:

三、parameters:用 schema 收紧取值

模型生成的 arguments 是一段 JSON 字符串。schema 能缩小模型的取值范围,但在多数接口上并不保证模型一定遵守(例外见本节末尾)。以 boost_adgroup 为例,参数定义如下。档位和天数上限定义为常量,schema 和第四节的代码校验共用同一份取值:

TIERS = ("conservative", "standard", "aggressive")
MAX_BOOST_DAYS = 14

parameters={
    "type": "object",
    "properties": {
        "adgroup_id": {
            "type": "string",
            "description": "search_adgroups 返回的 adgroup_id,形如 AG-001,不能填广告组名称",
        },
        "tier": {
            "type": "string",
            "enum": list(TIERS),
            "description": "追投档位,学习期内的广告组不支持 aggressive",
        },
        "start_date": {
            "type": "string",
            "description": "追投开始日期,格式 YYYY-MM-DD,例如 2026-11-01",
        },
        "days": {
            "type": "integer",
            "minimum": 1,
            "maximum": MAX_BOOST_DAYS,
            "description": f"追投天数,1 到 {MAX_BOOST_DAYS} 之间的整数",
        },
    },
    "required": ["adgroup_id", "tier", "start_date", "days"],
    "additionalProperties": False,
}

这份定义里有几处写法值得说明:

封闭集合用 enum。 追投档位、订单状态这类取值固定的参数,应当用 enum 列出全部合法值。如果只声明为 string,再在描述里写「请填 conservative、standard 或 aggressive」,模型仍可能根据用户原话填入「激进档」。

日期和 ID 给出格式与示例。 JSON Schema 有 format: "date",但不同模型和服务对它的遵守程度不一。更稳妥的做法是在 description 里写明 YYYY-MM-DD 并给出示例,代码中再按这个格式解析;解析失败时,把期望的格式回灌给模型。数值范围同理:minimum 和 maximum 写进 schema,代码里再检查一次。

设置 additionalProperties: False。 它告诉模型不要传入未声明的字段,例如 currency 或 note。如果模型仍然传了,上一篇的 dispatch 在调用工具前用 inspect.signature(...).bind(...) 检查参数,会把多余的字段作为参数错误回灌,而不是让它进入工具函数。

可选参数要真正可选。 一个字段写进了 properties 但不在 required 中,模型有时仍会自行填一个值。如果这个字段对当前流程并不必要,最简单的做法是不放进 schema。

返回值只保留下一步需要的字段。 搜索广告组时,如果把定向配置、创意素材 URL 等全部返回,不仅占用上下文,还可能让模型把某个无关的 ID(例如创意 ID)误当作 adgroup_id。search_adgroups 的返回值只保留 adgroup_id、name、tiers、daily_budget 四个字段;模拟数据里的 learning(是否在学习期)已经体现在 tiers 中,不再单独返回。

部分服务支持严格模式,例如 OpenAI 在工具定义中设置 "strict": true 后,服务端会保证生成的参数符合 schema。严格模式要求所有字段都列入 required,并设置 additionalProperties: False。很多兼容 OpenAI 协议的服务不支持这个选项,所以本系列不依赖它;即使开启了严格模式,档位是否适用于该广告组、余额是否充足这类业务规则,schema 也无法表达,仍然需要代码检查。

四、校验分层:解析、校验、执行

schema 是对模型的约束,最终的检查必须在代码里完成。按出错的位置,检查可以分为三层:

层次检查内容处理方式
解析层arguments 不是合法的 JSON 对象,参数名与函数签名不符,或类型与 schema 不符回灌错误;JSON 不合法时附上原文的前 200 个字符
校验层取值不在 enum 中、日期格式错误、数值超出范围回灌期望的格式或合法取值,不要只返回 invalid
执行层业务规则不满足,例如该广告组不支持此档位、账户余额不足、ID 不存在回灌具体原因;鉴权失败、配额耗尽和代码 bug 则终止

解析层就是上一篇 ToolRegistry.dispatch 已经做的事;校验层和执行层的检查写在工具函数里。boost_adgroup 中这两层的代码如下:

# 校验层:枚举、日期格式、数值范围
if tier not in TIERS:
    return {"error": f"tier 必须是 {'、'.join(TIERS)} 之一,收到:{tier}"}
boost_start = parse_date(start_date)
if boost_start is None:
    return {"error": f"start_date 必须是 YYYY-MM-DD,例如 2026-11-01,收到:{start_date}"}
if boost_start < mock.today:
    return {"error": f"追投开始日期不能早于今天 {mock.today.isoformat()},收到:{start_date}"}
if isinstance(days, bool) or not isinstance(days, int) or not 1 <= days <= MAX_BOOST_DAYS:
    return {"error": f"days 必须是 1 到 {MAX_BOOST_DAYS} 之间的整数,收到:{days!r}"}

# 执行层:业务规则
adgroup = mock.adgroups_by_id[adgroup_id]
if tier not in adgroup["tiers"]:
    reason = ",该广告组仍在学习期" if adgroup["learning"] else ""
    return {
        "error": f"「{adgroup['name']}」({adgroup_id})不支持 {tier} 档{reason},"
        f"可用档位:{'、'.join(adgroup['tiers'])}"
    }
daily_budget = adgroup["daily_budget"][tier]
prepaid_amount = daily_budget * days
if prepaid_amount > self.balance:
    return {"error": f"账户余额不足:需预付 {prepaid_amount} 元,当前余额 {self.balance} 元"}

days 的检查排除了 bool:在 Python 里 True 是 int 的子类,不排除的话,模型传入 true 会被当成 1 天。dispatch 按 schema 检查类型时同样排除了 bool;工具函数里再检查一次,是因为它也可能不经 dispatch 被直接调用。parse_date 只接受 YYYY-MM-DD;date.fromisoformat 在 Python 3.11 之后也接受 20261101 这类写法,这里不用它。

每条错误都说明期望什么、实际收到了什么,让模型能据此修改参数。例如模型把档位写成「激进档」,会收到:

{"error": "tier 必须是 conservative、standard、aggressive 之一,收到:激进档"}

只返回 {"error": "validation failed"},模型看不到函数实现,无从判断该改哪个参数。是否回灌的判断标准与上一篇相同:模型能通过修改参数解决的错误回灌,修改参数也解决不了的错误终止。参数校验失败属于前者。

五、工具粒度:何时合并,何时拆开

查询报表的任务拆成了三轮:搜索计划、查询报表、给出回答。如果把追投也拆成「搜索计划、搜索广告组、查询可用档位、计算预算、下单」五个工具,每多一步,模型就多一次选错工具或填错参数的机会。假设各步相互独立、单步成功率为 95%,五步连续成功的概率约为 77%。

合并的条件是:中间步骤的结果只供模型使用,用户不需要看到,也不需要确认。例如用户说的是计划名,并不关心 campaign_id,这时可以提供一个 get_report_by_name,在工具内部完成查找和查询,链路就少了一轮。

以下两种情况不应合并:

减少一个不必要的工具,通常比反复修改 description 更有效。工具越多,描述之间越容易相互干扰,模型越难选对。

六、写操作:触发条件、ID 白名单与确认

search_adgroups 是只读操作,参数填错的代价是多一次查询。boost_adgroup 会扣款,改变的是外部系统的状态。schema 只能检查参数格式,无法阻止模型在不该下单的时候下单。因此写操作需要三层防护:

  1. 在 description 中限定触发条件。 写明只有用户明确要求追投时才能调用;用户只询问预算或可用档位时,只能查询。
  2. 检查 adgroup_id 白名单。 传入的 ID 必须出现在本次 run 中 search_adgroups 的返回结果里。白名单由代码维护,不提供给模型,模型编造的 AG-999 会被拒绝。
  3. 执行前取得用户确认。 扣款、发送邮件、删除数据这类操作,模型调用一次就执行是不够的,必须有一个模型无法绕过的确认步骤。

后两层都需要记住本次 run 里发生过什么:搜索返回了哪些 ID,哪份方案在等待确认。源码把这些状态放进 BoostSession,两个追投工具是它的方法:

@dataclass
class BoostSession:
    balance: int
    seen_adgroup_ids: set[str] = field(default_factory=set)  # adgroup_id 白名单,不提供给模型
    pending_order: dict | None = None  # 已通过校验、等待用户确认的追投
    approved_order: dict | None = None  # 用户已确认、尚未执行的追投
    placed_orders: list[dict] = field(default_factory=list)

    def approve_pending_order(self) -> None:
        """由入口在用户确认后调用;模型无法通过任何工具改变确认状态。"""
        self.approved_order, self.pending_order = self.pending_order, None

    def revoke_approval(self) -> None:
        """由入口在每次 run 结束后调用:确认只对紧接着的一次 run 有效,未执行的确认作废。"""
        self.approved_order = None

白名单。 search_adgroups 把返回的 ID 记入 seen_adgroup_ids,boost_adgroup 第一步就核对:

self.seen_adgroup_ids.update(adgroup["adgroup_id"] for adgroup in adgroups)
if adgroup_id not in self.seen_adgroup_ids:
    return {"error": f"{adgroup_id} 不在本次 search_adgroups 的结果中,请先搜索广告组"}

白名单检查放在写操作执行时,而不是搜索时:搜索只负责记录哪些 ID 合法,追投时再核对。ID 不在白名单中时回灌错误,模型可以先调用搜索再重试。每个 run 创建自己的 BoostSession,上一次 run 搜到的 ID 不能用于这一次。

确认。 boost_adgroup 通过全部校验后,把参数整理成方案 order,再与确认状态比较。一次只处理一个方案:已有确认过的方案时,只有参数完全一致才执行;已有待确认的方案时,不接受另一个方案;两者都没有(第一次调用)时不扣款,只记下方案并返回待确认状态:

if self.approved_order is not None and order != self.approved_order:
    return {
        "error": "用户确认的是另一个方案,本次只能按已确认的参数执行;如需调整,请向用户说明并等待新的确认",
        "approved_order": self.approved_order,
    }
if self.pending_order is not None and order != self.pending_order:
    return {
        "error": "已有一个方案在等待用户确认,一次只能为一个广告组下单;请先向用户说明该方案",
        "pending_order": self.pending_order,
    }
if self.approved_order is None:
    self.pending_order = order
    return {
        "status": "confirmation_required",
        "message": "追投会立即扣款,需要用户确认。请向用户说明以下方案并等待确认,确认前不要再次调用 boost_adgroup。",
        "order": order,
    }

「一次只处理一个方案」是必要的。如果模型在同一轮为两个广告组各下一单,而后一单覆盖前一单,用户只确认了其中一单;确认后模型往往把两单都重新提交,先提交的那单与已确认的方案不一致,如果因此清掉确认、重新进入待确认,两单都执行不了,用户会被反复要求确认。现在第二个方案直接被拒绝,错误信息告诉模型先说明已有的方案。

模型据此给出方案,本轮没有 tool_calls,循环从出口一退出。入口 __main__.py 发现 session.pending_order 不为空,就按代码记下的参数向用户展示方案,而不是转述模型写的文字,避免二者不一致:

待确认的追投:「商品卡成交-老客」(AG-002),aggressive 档,2026-11-01 起 2 天,日预算 400 元,预付 800 元
确认执行?[y/N]

用户确认后,入口调用 session.approve_pending_order(),再以 history=result.messages 继续 run_agent,在同一份 messages 上追加一条「确认,按上述方案执行追投」。模型再次调用 boost_adgroup,参数与已确认的方案一致,这次才扣款并返回追投单号 BO-001。每次 run 结束后,入口都会调用 session.revoke_approval()。

这套设计有三个要点:

确认后必须沿用同一份 messages 和同一个 BoostSession。如果开始一个新的 run,搜索结果和白名单都会丢失,模型只能重新搜索。

系统的风险边界由开放给模型的工具决定。没有接入写操作工具的 Agent,最坏的结果是给出错误的回答;接入了扣款工具,就必须叠加上述检查。

七、源码与调试

本篇的调度循环与上一篇相同,确认后通过上一篇介绍过的 history 参数在同一份 messages 上继续。代码在 agent-in-action/ad-boost 中:

文件内容
src/ad_boost/tools/boost.pyBoostSession(白名单与确认状态)、search_adgroups、boost_adgroup
src/ad_boost/__main__.py按代码记录的参数向用户确认,确认后以 history 继续 run
src/ad_boost/mock/adgroups.json三个广告组的档位与日预算
src/ad_boost/mock/account.json账户余额 1000 元

追投接口同样是本地模拟,不需要广告平台账号。用系列的主线目标运行:

uv run python -m ad_boost "计划「双11-美妆拉新」11 月 1 日起追投两天。先查 10 月 31 日的 ROI,达到 1.5 用 aggressive 档,否则用 conservative 档。"

调试时可以观察以下几种情况:

输入预期行为
主线目标依次调用 search_campaign、get_campaign_report、search_adgroups,再调用 boost_adgroup 进入待确认
确认时输入 y再次调用 boost_adgroup,生成追投单 BO-001,余额从 1000 元变为 200 元
确认时直接回车不扣款,输出「已取消,未下追投单」
把档位写成「激进档」校验层回灌合法取值,模型改用 aggressive
要求「直播间引流-新客」用 aggressive 档执行层回灌「仍在学习期」和可用档位
追投三天 aggressive 档预付 1200 元超出余额,执行层回灌余额不足

八、常见问题排查

先查看每一轮的调用记录,确认模型选了哪个工具、传了什么参数,再决定修改 schema 还是代码。不要一出问题就修改 system prompt。

现象排查方向
模型不调用工具,直接编出可用档位和预算检查 description 是否写明了调用时机;system prompt 要求「预算和档位只能来自工具」
模型跳过搜索,用编造的 adgroup_id 调用追投在 boost_adgroup 的 description 中写明 ID 来源;代码中检查白名单
tier 填成中文或近义词使用 enum;校验失败时回灌合法取值列表
日期写成 11月1日 或 2026/11/1description 中给出格式和示例;代码按 YYYY-MM-DD 解析,失败时回灌期望格式
参数里出现 schema 未声明的字段设置 additionalProperties: False;dispatch 的签名检查会把多余字段作为参数错误回灌
参数类型不对,例如 adgroup_id 传成数组dispatch 按 schema 的 type 检查后回灌,不让错误类型进入工具函数
确认后反复要求确认,一笔也没执行检查是否一次提交了多个方案;工具应一次只接受一个待确认方案
模型在两个工具之间来回切换,或反复调用同一个工具检查工具描述是否重叠,或者是否缺少模型完成任务真正需要的工具
一次搜索返回了十几 KB 的 JSON裁剪返回字段,只保留下一步需要的 adgroup_id、name、tiers、daily_budget

小结

模型只能看到 schema。description 决定模型能否在正确的时机调用工具,要写清职责、调用时机和参数来源;enum、required、additionalProperties 和数值范围用来收紧参数的合法取值;schema 之外的检查由代码分三层完成,模型能通过修改参数解决的错误回灌,并说明期望的格式。

工具粒度上,只供模型使用的中间步骤可以合并,需要用户确认或涉及写操作的步骤必须拆开。扣款类写操作除了 schema 约束,还要在代码中检查 adgroup_id 白名单,并在执行前取得用户确认。

schema 写好之后,下一个问题是上下文的增长:广告组搜索结果、追投回执每一轮都会随 messages 重发。下一篇讨论哪些内容可以裁剪,哪些结论需要单独保存到记忆中。


–views
Share this post on:

Previous Post
对抗失忆与噪声:上下文裁剪与记忆挂载
Next Post
Agent 调度循环:工具调用、结果回灌与三个出口