先交代一个背景:我把公司内部的一套 AI Agent 平台从零搭起来的时候,最头疼的其实不是模型效果,而是工具调用太“自由”。有一回周五晚上,一个只读的订单查询工具,因为 Agent 在多轮对话里把参数理解错了,直接变成写接口调用,往生产库里塞了一条脏数据。那一刻我彻底想明白一件事:AI Agent 平台搭建,重头戏不是把 LangGraph 跑通,而是给工具调用立规矩。我们内部把这套治理体系叫做 Dogwood,它负责工具注册、契约校验、权限控制、限流熔断,还有整条链路的可观测性。这篇文章就是 Dogwood 从零到上线的完整工程拆解,给正在做 Agent 平台或者准备接 LangGraph 的同学一个可复用的参考。
1. 工具调用为什么会失控:拆开 LangGraph 看“手”是怎么乱动的
1.1 LangGraph 里工具调用的本质是一个“模型说了算”的执行器
不管你是用create_react_agent还是手动编排ToolNode,LangGraph 里工具调用的链路其实非常直白:模型在推理时如果觉得需要外部信息或执行某个动作,就输出一个结构化指令,里面包含工具名和参数 JSON;LangGraph 的节点负责解析这些指令,找到对应的 Python 函数,把参数传进去执行,然后把执行结果再塞回消息列表交给模型继续推理。
这个设计本身没有问题,问题出在“谁来约束模型的指令”。模型不是严格按照你的工具清单来行动的,它会猜。它猜工具名、猜参数格式、猜返回结构。我见过最典型的翻车场景是这样:
# 模型输出这样的 tool_call 并不罕见 tool_call = { "name": "query_order_status", "arguments": {"order_id": "20230415-001", "date": "下周三"} }date字段期望的是YYYY-MM-DD格式,结果模型给你传一个“下周三”。如果没有一层严格的校验器,这个工具就会直接报错,而 Agent 拿到的反馈是“参数错误”,它并不知道怎么修正,于是整个多轮任务卡死。更危险的是,如果模型幻觉出一个并不存在的工具名,恰巧这个工具名又跟某个真实存在但功能相反的接口重名,后果就不只是任务失败了。
1.2 我接完二十三个工具之后看到的四个失控症状
工具少的时候,问题不明显。工具一多,症状就全冒出来了。
第一个是命名混乱。同一个“查订单”的需求,库里同时存在query_order、get_order、order_detail三个工具,功能几乎一样,description 写法还各不一样。模型每次选哪个全靠随机,甚至连工具维护者自己都搞不清该给 Agent 暴露哪一个。
第二个是参数自由发挥。模型在生成 JSON 参数的时候,特别喜欢“填一个看起来合理的值”。比如一个工具要求user_id必须属于当前会话用户,但模型可能从上下文里翻出一个历史用户 ID 填进去。工具执行后返回的是别人的数据,更可怕的是这个数据还会被模型读取并呈现给当前用户。
第三个是没有权限边界。大部分团队第一版工具都是直接把函数丢给ToolNode,没有任何权限判断。等于所有用户都能通过 Agent 间接调用所有工具,哪怕这个工具背后连着内部的删除接口。我在前面提到的“只读工具被当写接口用”,本质就是这个原因。
第四个是并发被打爆。Agent 的多轮循环意味着一个用户请求在几秒内可能触发五到十次工具调用。如果模型在一个步骤里同时发起三四个tool_calls,工具的瞬时 QPS 会直接翻倍。多个用户同时在线时,下游服务很快就扛不住了。这个问题的表现是“Agent 平台一上线就卡死”,但根因根本不在模型,而在工具调用层没有流量控制。
1.3 失控的根因:工具调用本质是远端代码执行
想明白上面这些现象之后,我的结论是:工具调用不是普通的函数调用,它等于在 Agent 平台上开了一个“模型可控的远程执行通道”。模型的输出天然带有不确定性,你把它直接映射到代码执行,中间没有任何隔离、校验、审计的话,出问题只是时间问题。
所以 Dogwood 的核心定位很明确:它不是一个 Agent 编排框架,而是一个夹在模型和工具之间的治理层。它不关心你的业务逻辑怎么写,它只管三件事:这个工具能不能被调用、该不该在此时被调用、调用了之后结果能不能安全可信地返回给模型。这三件事做好了,Agent 平台才算有了基本盘。
2. 契约先于代码:工具注册中心是 Dogwood 的第一块基石
2.1 把工具的“使用说明书”结构化
工具给模型用的本质是一份结构化说明书。LangChain 里每个工具都有name、description、args_schema,但实际项目中大家写得很随意。Dogwood 做的第一件事就是把这些随意的东西收敛成注册中心里的正式契约,不允许任何人绕过注册表直接把函数挂到 Agent 上。
我们定义了一个ToolDescriptor结构:
from pydantic import BaseModel, Field from enum import Enum class ToolPermission(str, Enum): READ = "read" WRITE = "write" EXEC = "exec" class ToolDescriptor(BaseModel): name: str version: str description: str input_schema: type[BaseModel] output_schema: type[BaseModel] | None = None permission: ToolPermission timeout: float = 10.0 idempotent: bool = False rate_limit_key: str = "" allowed_roles: list[str] = [] sensitive_fields: list[str] = []每个工具上线前必须填写这份契约。其中description是最关键的字段,它决定模型什么时候调用这个工具。我踩过很多坑之后总结出一个有效的写法模板:先说明工具的用途,再说明什么情况下不要用,最后给一个典型参数示例。
# 好的 description description = ( "用于查询用户的订单状态,输入 order_id 精确匹配," "返回订单当前状态与物流单号。仅当用户主动询问订单状态时使用;" "如果用户需要修改订单或退货,不要调用本工具,应调用 order_modify 工具。" "示例:order_id='20230415-001'。" )这里有一个容易被忽略的点:description本身就是模型的提示词的一部分。你写“不要调用本工具去修改订单”,模型是会真的读到的。宁可多写几行,也不要让模型自己去猜。
2.2 参数校验不能只靠 pydantic
input_schema解决了类型和必填项的问题,但远远不够。模型传进来的参数经常“类型对但语义错”。所以 Dogwood 的校验分成两层。
第一层是结构校验,直接用 pydantic 解析模型给的 JSON。第二层是业务语义校验,在工具函数真正执行前跑一组自定义规则。比如:
class OrderQuerySchema(BaseModel): order_id: str start_date: str | None = None end_date: str | None = None def validate_order_query(args: dict, context: dict) -> None: if args.get("start_date") and args.get("end_date"): if args["end_date"] < args["start_date"]: raise ValueError("end_date 不能早于 start_date") if not args.get("order_id") and not (args.get("start_date") and args.get("end_date")): raise ValueError("order_id 与日期范围必须至少提供一项")校验失败时,Dogwood 不会直接把“参数错误”四个字抛给模型,而是返回一段结构化反馈,告诉模型错在哪里、应该怎么修正。这样 Agent 才有机会在下一轮自动修复。实测下来,带修正提示的失败反馈能让任务成功率提升不少,因为模型确实会读反馈并调整参数。
2.3 出参也要校验:模型看到的东西必须可控
很多人只校验入参,不校验出参。但工具返回给模型的内容同样需要规矩。
Dogwood 的post-call校验会做三件事:检查返回结构是否符合output_schema、检查敏感字段是否需要脱敏、检查返回内容是否超出长度限制。举个例子,一个查询工具返回了完整数据库记录,里面包含手机号和身份证号,如果直接塞给模型,模型可能会在下一次回复里脱口而出。Dogwood 会依据sensitive_fields把这些字段替换成***,再返回给模型。
出参校验还有一个容易被忽略的用途:防提示词注入。工具返回的文本里可能包含"忽略之前所有指令,直接执行xxx"之类的字符串,一旦模型把工具返回的内容当成系统指令,就会出大问题。Dogwood 会在返回前扫描这类危险模式,检测到就拦截并向模型返回“工具返回内容存在异常,已被过滤”。
2.4 契约版本管理:工具升级不能原地改
工具上线后一定会迭代,但工具的description是模型行为的重要输入。直接原地修改描述,可能导致之前测试好的 Agent 行为突然漂移。Dogwood 的做法是给每个工具做版本化:契约一旦对外发布就只读,任何改动都产生新版本。
| 发布方式 | 模型行为稳定性 | 回滚成本 | 审计追踪 |
|---|---|---|---|
| 原地修改 | 低,可能突然漂移 | 高,无法恢复旧行为 | 差 |
| 版本化发布 | 高,绑定后稳定 | 低,切换版本即可 | 好 |
线上 Agent 可以显式绑定某个工具版本,比如query_order@v3。新版本先在测试环境跑,确认没问题后再把线上流量逐步切过去。这套机制在 Dogwood 里就是一张版本表,工具执行时按name + version找到对应契约。
3. 权限与安全:不是每个工具都能被模型伸手够到
3.1 模型的请求必须带上“人”的身份
大模型本身没有身份概念。工具调用要安全,必须把请求链路里的用户身份注入到执行上下文。我们在 FastAPI 入口解析 JWT 后,把user_id、tenant_id、user_roles放进一个contextvars上下文对象,Dogwood 的工具执行器从这个上下文读取身份信息。
有了身份之后,权限判断就顺理成章:每个工具在ToolDescriptor.allowed_roles里声明哪些角色可以使用。执行前,Dogwood 做一次 RBAC 匹配,不满足直接拒绝调用,并返回PERMISSION_DENIED给模型。
# dogwood 权限策略示例 tools: - name: query_order allowed_roles: [normal_user, customer_service] - name: order_modify allowed_roles: [customer_service, admin] - name: delete_user_account allowed_roles: [admin]这里还涉及租户隔离。SaaS 场景下,一个用户的 Agent 绝不能通过工具读到另一个租户的数据。所以 Dogwood 在工具执行前会校验请求上下文里的tenant_id与工具参数中的业务数据是否一致。这个校验没法在通用层自动做,只能要求每个工具注册时提供一个tenant_id_extractor函数,Dogwood 统一调用。
3.2 写操作和危险操作必须人机确认
不是所有工具都应该由模型自动执行。我们将工具按permission分为三类:read类直接放行,write类和exec类默认需要人工确认。这里的“人工确认”不是产品层面的按钮,而是执行链路的强制卡点。
在 LangGraph 里,这个机制可以用interrupt()配合图状态实现。当模型请求调用一个write类工具时,图执行到确认节点会暂停,前端弹出确认框展示“Agent 将要执行:修改订单状态为已发货”,用户点击确认后图恢复执行,真正调用工具函数。
def call_tool_with_confirmation(state): tool_call = state["next_tool_call"] descriptor = dogwood.get_tool(tool_call["name"], state["tool_version"]) if descriptor.permission in ("write", "exec"): # 挂起并等待用户确认 confirmed = interrupt({"tool_name": descriptor.name, "params": tool_call["arguments"]}) if not confirmed: return {"messages": [{"role": "tool", "content": "用户取消了该操作"}]} result = dogwood.execute(tool_call, state["user_context"]) return {"messages": [{"role": "tool", "content": result}]}这个设计从一开始就把“模型误调用写接口”的风险控制在发生之前。很多团队觉得人工确认打断体验,但对于平台型系统,宁可牺牲一点流畅度,也不要在生产环境承担失控风险。
3.3 返回内容脱敏与数据最小化
工具返回给模型的数据,遵循最小化原则会减少很多麻烦。Dogwood 的脱敏模块支持两类处理:字段级脱敏和内容级过滤。
字段级脱敏最简单,注册工具时声明sensitive_fields,执行后对返回 JSON 中的相应字段做掩码处理,比如手机号变成138****8000。内容级过滤稍微复杂一点,针对的是工具返回的长文本,比如客服聊天记录里可能包含用户无意间发送的银行卡号。Dogwood 内置了一个规则引擎,用正则模式匹配常见敏感信息并替换。
还有一点值得强调:脱敏之后的返回也要控制体积。一个工具把三千行日志返回给模型,模型会怎么处理?它会把无关内容也读进上下文,导致 token 消耗飙升,还分散注意力。Dogwood 的max_tokens_per_tool_result限制会在返回前截断超长内容,并附加一句“以上内容已截断,如需完整日志请调用日志详情工具”。
3.4 审计日志:每一次工具调用都要能追溯
安全体系的最后闭环,就是完整、不可变、可查询的审计日志。Dogwood 每次执行工具都会写入一条审计记录,包含:时间戳、trace_id、user_id、工具名和版本、入参摘要、返回状态、耗时、命中策略等。
这里有个小设计:我们不记录完整入参,而是记录经过脱敏的摘要。因为完整参数可能包含敏感业务数据,审计库如果被拖走,反而成了新的数据泄露出口。但在必要的时候,摘要加上 trace_id 足以定位到具体事件。审计日志使用独立存储,只追加不可修改,定期归档。
4. 并发、限流与熔断:Agent 扛并发不是加机器就能解决
4.1 理解 Agent 平台的“放大效应”
很多初做 Agent 平台的人以为“扛并发”就是把 LLM 推理服务或者后端 API 的水平扩容做好。实际上,工具调用层的并发模型比普通 Web 服务更凶险。
一个用户请求进来,Agent 要经过多轮推理。每轮推理如果模型决定调用工具,就会产生一次工具调用。我们用线上数据统计过,一次完整任务的工具调用次数平均是五次,复杂任务甚至到十次以上。也就是说,工具层的 QPS 是用户请求 QPS 的五到十倍。而且模型经常在一个步骤里并行发起多个tool_calls,这让工具调用流量呈脉冲式尖峰,不是平滑的。
所以 Dogwood 的并发治理不能用简单的“加几台机器”来解决。它必须在工具执行前做限流,在工具依赖的下游异常时做熔断,在工具卡死时做超时兜底。这一套组合拳,才是 Agent 平台“扛并发”的真正答案。
4.2 令牌桶限流:给工具调用装上刹车
限流算法里,令牌桶最适合这种突发流量场景。它允许一定程度的突发,但长期平均速率可控。Dogwood 在内存里为每个rate_limit_key维护一个令牌桶,支持用户级、工具级、全局级三种维度。
import asyncio import time class TokenBucket: def __init__(self, rate: float, capacity: int): self.rate = rate # 每秒补充令牌数 self.capacity = capacity # 桶容量,允许突发量 self.tokens = capacity self.updated_at = time.monotonic() self.lock = asyncio.Lock() async def acquire(self): async with self.lock: now = time.monotonic() self.tokens = min( self.capacity, self.tokens + (now - self.updated_at) * self.rate ) self.updated_at = now if self.tokens < 1: return False self.tokens -= 1 return True实际限流参数怎么定?不能拍脑袋。我们的做法是先对每个后端服务做压测,找到单实例能够承受的 QPS 上限,然后乘一个安全系数作为工具层限流阈值。比如订单查询接口压测到 80 QPS 时成功率开始下降,那就把工具限流阈值设在 50 QPS,留出余量。
还要注意限流的维度设置。用户级限流防止单个用户霸占太多资源;工具级限流保护下游服务;全局级限流保护整个 Agent 平台不被流量冲垮。三层并发生效,任何一个维度拒绝都会向模型返回“工具调用过于频繁,请稍后重试”。
4.3 熔断与降级:下游挂了不能让 Agent 死磕
限流解决的是流量过载,熔断解决的是下游故障。实践中我们见过这种情况:Agent 依赖的某个内部 API 挂了,但限流器没反应,于是每一次工具调用都卡在超时边缘,模型反复重试,把已经故障的下游拖得更惨,同时用户的请求全部卡住。
Dogwood 的熔断器是一个经典状态机:closed正常放行,open直接快速失败,half_open试探恢复。当一个工具调用的连续失败率超过 50% 且最近一分钟请求数超过 20 时,熔断器从closed切到open,持续时间 30 秒。这期间工具调用不会真正执行,而是立刻返回“当前服务暂不可用”。
class CircuitBreaker: def __init__(self, threshold_failure_rate: float = 0.5, min_requests: int = 20, open_seconds: float = 30): self.threshold_failure_rate = threshold_failure_rate self.min_requests = min_requests self.open_seconds = open_seconds self.status = "closed" self.failures = 0 self.total = 0 self.opened_at = 0 def allow(self) -> bool: if self.status == "open": if time.monotonic() - self.opened_at > self.open_seconds: self.status = "half_open" return True return False return True def record(self, success: bool): if self.status == "half_open": self.status = "closed" if success else "open" if not success: self.opened_at = time.monotonic() self.failures = 0 self.total = 0 return self.total += 1 if not success: self.failures += 1 if self.total < self.min_requests: return if self.failures / self.total >= self.threshold_failure_rate: self.status = "open" self.opened_at = time.monotonic()熔断器打开之后,Agent 不一定只能失败。我们在工具返回里增加一个suggestion字段,告诉模型“当前工具不可用,可以尝试降级方案”。比如天气查询服务挂了,模型可以改用另一个工具查询本地天气。这种降级路径虽然能力弱一些,但至少让用户感受到 Agent 还在工作。
4.4 超时与幂等:最后一道兜底防线
工具调用必须有默认超时,我们设置的是 10 秒。模型等工具执行结果的过程是同步的,一个工具卡死意味着整轮推理卡死,更严重的是模型可能会带着超时报错继续运行,产生更多错误重试。
幂等处理同样关键。模型的重试机制不可靠:一个创建工单的工具执行成功了,但网络超时让模型以为失败,于是再次调用同一个工具,生成了一张重复工单。Dogwood 的做法是:如果工具声明了idempotent=True,在入参里要求调用方必须传入request_id,Dogwood 缓存这个 ID 对应的执行结果。同一个request_id再次到达时,直接返回第一次的执行结果,不再重复执行。
4.5 压测验证:护栏对性能的影响
很多人担心加这么多治理逻辑会拖慢工具调用。我们用 Locust 做了一轮压测对比,模拟 100 个并发用户、每个用户触发 5 次工具调用,下游服务是一个简单的查询 API。
| 状态 | 用户并发 | 工具 QPS | 错误率 | p99 耗时 | 后端 CPU |
|---|---|---|---|---|---|
| 无限流无熔断 | 100 | 520 | 18.3% | 3.2 s | 95% |
| 限流+熔断+超时 | 100 | 480 | 0.6% | 1.1 s | 61% |
加了治理层之后,整体吞吐几乎不变,但错误率和后端压力显著下降。原因很简单:原来服务在过载边缘疯狂抖动,大量请求超时重试,实际有效吞吐反而低。护栏让服务始终运行在健康区间。这个结果也验证了一个观点:给工具调用立规矩,不是额外的开销,而是稳定的必要条件。
5. 可观测性建设:每次工具调用都要有据可查
5.1 一条链路上的 trace_id 贯穿
Agent 平台的排查难点在于调用链很长:HTTP 请求进来,LangGraph 跑多轮循环,每轮可能有多个工具调用,每个工具调用又嵌套下游服务。没有可观测性的时候,用户说“我的任务失败了”,你根本不知道是哪一轮、哪个工具、哪次参数出的问题。
Dogwood 的解法是从入口生成一个trace_id,通过contextvars传递到图的每一个节点、每一个工具调用。所有日志、指标、审计记录都带上这个trace_id。工具调用后端服务时,也会把这个trace_id放进 HTTP header 传递下去,形成全链路关联。
5.2 指标埋点要覆盖“被拦下来的请求”
可观测性最容易忽略的是治理层自己产生的拦截事件。我们给 Dogwood 埋了这样几类指标:工具调用总数、成功数、失败数、耗时分位数、限流拦截数、熔断拦截数、权限拒绝数。每个指标都带tool_name、version、user_role标签。
这些指标放到 Grafana 里看,效果非常直观。比如某个工具突然限流拦截数飙升,说明用户的并发请求超过了预设阈值;某个工具权限拒绝数异常,说明模型在试图调用不该调用的工具,这往往是description写得不够明确,需要优化。
5.3 通过一次线上事故看 Trace 的价值
有一次线上反馈:某个用户问“帮我查一下上个月的账单总额”,Agent 最终回复“暂不支持”。照理说这个功能是有的。我们通过trace_id拉出完整调用链,发现 Agent 在第一轮调用了账单查询工具,但工具抛错“日期格式错误”。关键在返回给模型的错误信息过长,模型没有仔细读,直接在第二轮回复了“暂不支持”。
根因很清楚:Dogwood 的校验错误虽然带了修正提示,但堆在错误堆栈最后面,模型根本没注意到。我们做了两个改进:一是缩短错误信息,把“修正建议”提到最前面;二是格式化错误输出,让模型更容易解析。改完之后类似任务的完成率明显提升。如果没有 trace 把链路拉到这一步,这种问题只能靠猜。
5.4 日志记录的原则:脱敏摘要 + 结构化字段
工具调用的原始参数和返回往往包含敏感业务数据,全量记录既不安全,也耗费存储。Dogwood 采用的结构化日志方案是:记录入参摘要、出参摘要、耗时、状态、触发规则。入参摘要会对脱敏字段做掩码处理,比如只记录user_id是否为空、order_id的长度和前缀。这样既保留了排查线索,又不会把用户完整数据写进日志系统。
6. 接进 LangGraph 项目的完整流程和踩坑记录
6.1 接入方式:包一层 Dogwood 执行器
Dogwood 不入侵 LangGraph 的内部逻辑,它只做一件事:把注册表里的原始工具函数替换成“经过治理层包裹的执行器”。具体来说,LangGraph 的ToolNode接收一个tools列表,我们把原始函数放入注册表后,用dogwood.wrap(original_tool)生成一个带校验、限流、熔断、审计的新函数,塞进tools列表。
from langchain_core.tools import tool from langgraph.prebuilt import ToolNode registry = ToolRegistry() registry.register(OrderTool()) # 原始业务工具 # Dogwood 包装后的工具才进入 LangGraph wrapped_tools = [dogwood.wrap(desc, registry) for desc in registry.tools()] tool_node = ToolNode(wrapped_tools)这里有个关键点:dogwood.wrap返回的不是普通函数,而是一个@tool装饰后的 LangChain 工具对象,且args_schema直接取自注册表里的input_schema。这样 LangGraph 在解析模型输出时,用的就是 Dogwood 的契约,而不是业务函数自己带的松散类型注解。
6.2 完整接入步骤
第一步,在项目启动时初始化注册中心,把所有工具扫进注册表,并做一次自检:schema 是否能正确解析、依赖服务是否可连通、权限配置是否合法。自检不通过的工不会被加载,宁可让 Agent 少一个能力,也不能让一个坏工具上线。
第二步,在 FastAPI 入口写一个中间件,解析用户身份并写入 Dogwood 的上下文。没有这一步,权限和租户隔离就是空谈。
from fastapi import Request import dogwood @app.middleware("http") async def inject_user_context(request: Request, call_next): token = request.headers.get("Authorization", "") user = parse_token(token) dogwood.set_context(user_id=user.id, tenant_id=user.tenant_id, roles=user.roles) # 为整个请求生命周期生成 trace_id dogwood.set_trace_id(uuid4().hex) return await call_next(request)第三步,把包装后的工具列表传给 LangGraph 的create_react_agent。第四步,在 Agent 图的边上加一个“执行前确认节点”,负责处理write/exec类型工具的人工确认。第五步,启动后观察指标,确认工具调用流程平稳。
6.3 踩坑一:pydantic 版本兼容问题
这是我们接入时遇到的第一个大坑。LangChain 生态早期版本对 pydantic v1 和 v2 的兼容性不一致,业务团队的工具函数用的是 v2 的BaseModel,但 LangGraph 内部的ToolNode在某些版本里期望 v1 的 schema,导致工具注册时直接抛校验异常。
解决办法是统一环境里的 pydantic 版本,并在注册表里强制使用同一个基类。现在新项目直接上 pydantic v2,并固定相关依赖的版本号。这一点写进团队规范,避免新人引入老依赖把环境搞乱。
6.4 踩坑二:同步阻塞工具吃掉事件循环
LangGraph 的异步执行流复用同一个事件循环。如果工具函数是同步阻塞的,比如用requests.get调下游 HTTP 接口,在并行调用多个工具时,阻塞会占住事件循环,其他协程全部排队,整个 Agent 看起来像死了一样。
我们当时的处理方案是把所有工具统一改成异步实现。对于一些无法改造的遗留同步工具,使用asyncio.to_thread包一层,并通过Semaphore限制线程池并发的最大数量。
import asyncio class SyncBridge: def __init__(self, max_threads: int = 32): self.semaphore = asyncio.Semaphore(max_threads) async def call(self, func, *args, **kwargs): async with self.semaphore: return await asyncio.to_thread(func, *args, **kwargs)线程池并发数要按工具的实际耗时调整。我们设过 128,结果某个慢工具把线程池占满,其他工具全部等待;后来调低到 32,并在注册表里对慢工具单独限制并发。
6.5 踩坑三:工具返回超长导致上下文爆炸
有一回 Agent 在分析日志时,一个工具返回了十万字符的原始日志,模型一轮就把上下文撑爆了,token 成本直接翻倍,任务还失败了。问题出在出参没有限制。
Dogwood 后来加了max_tokens_per_tool_result策略,默认 2000 个 token。超长返回会被截断,并附带一个截断标记。对于确实需要完整内容的场景,我们要求工具自行设计分页参数,让模型按需拉取。
6.6 工具灰度与回归验证
最后分享一个 Dogwood 上线后沉淀下来的工具发布流程。新工具不会直接暴露给生产 Agent,先走“影子模式”:Dogwood 记录模型会以什么参数调用这个工具、会返回什么结果,但不真正执行。观察一周,确认模型调用路径符合预期,再切换为真实执行。
每次修改工具的description或参数 schema,都要跑一遍回归测试集。我们维护了五六十条典型用户问题,覆盖每个工具的主要使用路径。发版后自动跑一遍,观察工具被调用的路径是否和设计一致。这一招能提前发现大多数因描述改动引发的模型行为漂移。
Dogwood 这套东西不是一天长出来的,最初只是一个几十行的参数校验函数,后来在一次次线上事故里被逼着长成了完整的治理层。我的体会是:工具调用治理不应该等到出事了再补,它应当是 Agent 平台的第一优先级。先给这双“手”立好规矩,再放开模型的能力边界,平台才能真正站稳。如果你也在做 Agent 平台,可以先把契约注册、权限校验、限流熔断这三件事做起来,亲手压一遍测,你会感受到“有规矩的工具调用”和“裸奔的工具调用”之间完全是两种运行体验。