上一篇实现了调度循环:模型请求调用工具,代码执行并把结果回灌到 messages,直到满足某个出口。循环本身没有多少可调的地方,同一个循环接上不同的工具,效果差别主要来自工具的 schema。
模型看不到工具的 Python 实现,只能看到 tools 参数里的那段 JSON。schema 写得含糊,模型就只能猜;参数约束写得宽松,模型就可能填入不合法的值。本文继续不使用框架,沿用上一篇的循环,把任务从「查报表」推进到「追投」。追投拆成两个工具:
| 工具 | 作用 | 读写 |
|---|---|---|
search_adgroups | 按计划和追投开始日期,返回可追投的广告组及各档位日预算 | 只读 |
boost_adgroup | 为指定广告组下追投单,提交即从账户余额预付扣款 | 写操作 |
模拟数据中,计划「双11-美妆拉新」下有三个广告组。只有已过学习期的 AG-002 支持 aggressive 档:
adgroup_id | 名称 | 学习期 | 可用档位 | 日预算(元) |
|---|---|---|---|---|
AG-001 | 直播间引流-新客 | 是 | conservative、standard | 180、260 |
AG-002 | 商品卡成交-老客 | 否 | conservative、standard、aggressive | 150、220、400 |
AG-003 | 短视频种草-兴趣人群 | 是 | conservative | 110 |
本文用到的术语如下,其中回灌、终止与前两篇的定义一致:
| 术语 | 含义 |
|---|---|
| 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 没写清,调整顺序也解决不了问题。
上一篇查询报表时,模型之所以知道要先调用 search_campaign,依据的是 get_campaign_report 描述里的第二句:
description=(
"按 campaign_id 和日期查询计划日报表(ROI、消耗、成交额等)。"
"没有 campaign_id 时,先调用 search_campaign。"
"报表 T+1 产出,只能查询昨天及更早的日期。"
)
去掉第二句,模型可能直接编一个形如 CP-xxxx 的 ID 去查报表。如果这个 ID 恰好对应账户里的另一个计划,接口不会报错,模型会用别的计划的数据给出一个看似正常的回答。
二、description:写清职责、调用时机与参数来源
description 的读者是模型,不是开发者。模型看不到源码,也不了解团队内部的约定,只能依据当前对话和这段文字做判断。因此每个工具的 description 至少要写清三件事:
- 职责。 用一句话说明工具做什么、返回什么。「处理广告投放相关事务」这类描述无法帮助模型区分工具。
- 调用时机。 缺少哪些信息时要先调用其他工具,哪些情况下不应调用。例如用户只问「能追投多少」「要花多少钱」时,只能查询,不能下单。
- 参数来源。 关键参数应该从哪里取得。例如
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:只写「扣款」,模型就可能回避调用。
实践中有两种常见的写法问题:
- 调用规则只写在 system prompt 里,工具的 description 却很简略。 两者分工不同:system prompt 适合放全局规则,例如「ROI、预算等数据只能来自工具结果」;「先搜索再追投」这类只与某个工具相关的规则,应写进该工具的 description。description 和工具绑定在一起,模型在决定是否调用这个工具时直接读到它;工具增减时,规则也随之增减,不会在 system prompt 里留下过时的说明。
- 多个工具的 description 语义重叠。 如果
search_adgroups和list_adgroups都写成「查找广告组」,模型无从判断该用哪一个,只能随机选择。能合并的工具应当合并;不能合并时,要在描述里写明区别,例如「按计划检索可追投的广告组」和「按名称查找单个广告组」。
三、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,在工具内部完成查找和查询,链路就少了一轮。
以下两种情况不应合并:
- 中间结果需要用户确认。 追投前用户要看到具体的广告组、档位和金额,搜索与下单必须分成两步。
- 合并会绕过安全检查。 如果让
boost_adgroup直接接收广告组名称、在内部完成搜索和下单,模型就跳过了第六节的 ID 白名单。写操作的参数必须来自一次独立的、结果可核对的查询。
减少一个不必要的工具,通常比反复修改 description 更有效。工具越多,描述之间越容易相互干扰,模型越难选对。
六、写操作:触发条件、ID 白名单与确认
search_adgroups 是只读操作,参数填错的代价是多一次查询。boost_adgroup 会扣款,改变的是外部系统的状态。schema 只能检查参数格式,无法阻止模型在不该下单的时候下单。因此写操作需要三层防护:
- 在 description 中限定触发条件。 写明只有用户明确要求追投时才能调用;用户只询问预算或可用档位时,只能查询。
- 检查
adgroup_id白名单。 传入的 ID 必须出现在本次 run 中search_adgroups的返回结果里。白名单由代码维护,不提供给模型,模型编造的AG-999会被拒绝。 - 执行前取得用户确认。 扣款、发送邮件、删除数据这类操作,模型调用一次就执行是不够的,必须有一个模型无法绕过的确认步骤。
后两层都需要记住本次 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()。
这套设计有三个要点:
- 确认状态只能由入口改变。 没有任何工具能把方案标记为已确认,模型无法通过调用工具绕过确认。
- 确认绑定具体参数。 确认后模型若改了档位或天数,调用会被拒绝,不会按新参数扣款;已确认的方案保持不变,模型仍可按原参数执行。
- 确认只生效一次。 执行后
approved_order清空,模型再调用一次同样的追投,仍需重新确认,不会重复扣款。确认后的那次 run 如果没有执行(例如模型只回复了文字),run 结束时确认也会作废,不会留到后面的对话里。
确认后必须沿用同一份 messages 和同一个 BoostSession。如果开始一个新的 run,搜索结果和白名单都会丢失,模型只能重新搜索。
系统的风险边界由开放给模型的工具决定。没有接入写操作工具的 Agent,最坏的结果是给出错误的回答;接入了扣款工具,就必须叠加上述检查。
七、源码与调试
本篇的调度循环与上一篇相同,确认后通过上一篇介绍过的 history 参数在同一份 messages 上继续。代码在 agent-in-action/ad-boost 中:
| 文件 | 内容 |
|---|---|
src/ad_boost/tools/boost.py | BoostSession(白名单与确认状态)、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/1 | description 中给出格式和示例;代码按 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 重发。下一篇讨论哪些内容可以裁剪,哪些结论需要单独保存到记忆中。