☰
Agent请求全链路拆解:从请求组装到沙箱执行与重试机制
2026/10/28 14:34:50 网站建设 项目流程

一次 Agent 请求能拆多细?从我在生产环境调 Agent 的经验来说,拆到“请求组装→tool_calls→沙箱执行→报错重试”这四个环节,基本就能把 90% 的问题定位清楚了。很多刚接触 Agent 开发的朋友,看了不少框架文档,跑通了 demo,但一遇到 IndexError、docker compose 起不来、tool_calls 返回的东西解析失败就懵了——因为脑子里没有一个完整的链路图。这篇文章就用一次真实的 Agent 请求做标本,把每一环发生了什么、为什么这么设计、常见坑在哪,一次讲透。适合正在写 Agent 业务代码、纠结“模型怎么才能可靠调用工具”以及准备上沙箱的开发者参考。

1. 请求组装:先把上下文和工具清单备好

1.1 一次请求到底长什么样

所谓请求组装,就是在大模型 API 真正发出 HTTP 调用之前,把 messages 数组、tools 数组、temperature 等参数拼好。这个步骤看着简单,其实直接决定了后面 tool_calls 的质量。

我常用的请求结构大致是这样:

{ "model": "gpt-4o-mini", "messages": [ {"role": "system", "content": "你是一个数据分析助手,可以调用工具查询订单数据。"}, {"role": "user", "content": "查一下昨天华东区的订单量"} ], "tools": [ { "type": "function", "function": { "name": "query_order_count", "description": "查询指定区域在指定日期的订单量,参数格式见 schema。", "parameters": { "type": "object", "properties": { "region": {"type": "string", "enum": ["华东", "华北", "华南"]}, "date": {"type": "string", "format": "date"} }, "required": ["region", "date"] } } } ], "tool_choice": "auto" }

messages 是“对话历史”,tools 是“能力清单”,这两者组装好了,模型才知道自己有什么牌可以打。很多初学者只传当前这一条用户消息,不带上之前的工具调用结果,结果模型下一轮就“失忆”了,这是请求组装最常见的问题。

1.2 工具描述写不好,模型就瞎调用

tools 数组里的 description 不是随便写两句就行的。模型选择工具的依据几乎完全依赖这段文本描述。我踩过一个很典型的坑:工具名叫 get_user_info,description 写的是“获取用户信息”,参数是 user_id,结果模型在用户问“帮我查一下张三的会员等级”时,根本不调用工具,而是直接凭训练记忆瞎编。后来我把 description 改成“根据用户ID查询用户详细信息,包括会员等级、注册时间、最近登录时间,返回JSON字符串”,调用率立刻上来了。

写工具描述有几点实操经验:

  • 描述里说清楚“什么时候该用”,不要只说“这个工具是干嘛的”。
  • 参数说明里给出边界值,比如日期格式写“YYYY-MM-DD”,枚举值写全。
  • 复杂参数给一个 few-shot 示例,放在 description 末尾也行。
  • 多个工具之间描述不要语义重叠,否则模型选错工具的概率会明显上升。

1.3 上下文取舍:不是越长越好

组装请求的时候还有个隐形问题:上下文过长。Agent 跑几轮之后,messages 里会堆积大量工具调用结果,尤其是那种返回大段 JSON 的工具,一轮就能吃掉几千 token。这时候如果不做处理,请求会变慢、变贵,更重要的是模型在超长上下文里的注意力会涣散,tool_calls 质量直线下降。

我常用的做法是分层保留:system 永远保留,最近两轮的用户消息和工具结果原样保留,更早的历史做摘要压缩,或者只保留其中的关键字段。还有一种粗暴但有效的方案:如果工具结果超过一定长度,截断到前 N 个字符,并追加一句“结果过长已截断”。

截断工具结果这个操作,务必在消息里明确标注,不然模型会以为看到的就是完整的返回数据。我见过有同事没标注截断,模型基于残缺数据分析出错误结论,整整排查了半天。

2. tool_calls:模型是怎么“决定”调用工具的

2.1 先弄懂 tool_calls 不是函数调用

先说个容易混淆的概念:tool_calls 不是代码里的函数调用,而是模型在生成回复时输出的一种“调用意图声明”。它本质上是文本生成,只不过遵循了 API 约定的结构化格式。模型并没有“执行”任何东西,它只是告诉你:“我要用 query_order_count 这个工具,参数是这个。”

所以,真正的执行逻辑必须由我们自己写代码完成。模型的产出物是一个 JSON 结构:

{ "id": "call_abc123", "type": "function", "function": { "name": "query_order_count", "arguments": "{\"region\": \"华东\", \"date\": \"2024-01-15\"}" } }

注意这里 arguments 是一个字符串,不是嵌套对象。我之前见过不少人直接拿 message.tool_calls[0].function.arguments 当对象用,一访问属性就报错,其实必须先 JSON.parse 一次。

2.2 arguments 解析的隐藏坑

arguments 是字符串,就带了第一个风险:它不一定是合法 JSON。模型在生成时可能多一个逗号、少一个引号,或者用单引号代替双引号。GPT 系列在绝大多数时候能给出规范 JSON,但长参数、复杂嵌套对象时,偶尔会翻车。

我建议解析工具参数不要用裸 JSON.parse,要包一层容错逻辑:

  • 先尝试 JSON.parse。
  • 失败后尝试修复常见问题:去掉末尾多余逗号、将单引号替换为双引号、补充缺失的右括号。
  • 仍然失败,就把解析错误作为工具执行结果返回给模型,让它重新生成参数。

这里有一个很重要的设计理念:工具的报错信息可以返回给模型。模型不是上帝,它也会写错参数。你把错误信息当作工具返回结果拼进 messages,下一轮模型就能自我修正。这是 Agent 能“自动重试”的根基。

2.3 一次请求里出现多个 tool_calls

有些场景下模型会在一条回复里同时发起多个工具调用。比如用户问“对比一下华东和华北昨天的订单量”,模型可以直接发起两个 query_order_count 调用,分别传不同的 region,这样一次往返就拿到两个结果,效率很高。

多个 tool_calls 的执行要注意并发控制。如果工具是查数据库,并发没问题;如果工具是操作同一个文件,就要谨慎了。我的习惯是:幂等工具并发执行,有副作用或依赖关系的工具串行执行。

多 tool_calls 结果的回填顺序也要匹配好。每条 assistant 消息里的 tool_calls 都有一个 id,对应的 tool 消息里也有一个 tool_call_id,必须一一对应。一旦错位,模型上下文就会出现混乱,后面的重试会连环出错。

2.4 tool_choice 与强制工具调用

默认情况下 tool_choice 是 "auto",模型自己决定要不要调用工具。但有些场景我们希望模型必须调用工具,比如“先查数据再回答”类的问题,如果模型跳过工具直接凭记忆回答,结果一定不准。

这时候可以设置 "tool_choice": {"type": "function", "function": {"name": "query_order_count"}},强制模型调用指定工具。不过这个设置要慎用:如果你只给了一个工具,强制调用没问题;如果你给了五个工具,强制指定某一个,模型仍然可能在某些情况下并不需要它,结果就是它硬着头皮调用,返回一堆无意义参数。

3. 沙箱执行:把不可信输出关进笼子里

3.1 为什么必须沙箱执行

很多初学者觉得“工具调用能有什么风险,不就是执行一段 Python 吗”,直到某次模型生成了 os.system("rm -rf /") 或者 subprocess.Popen("curl http://evil ..."),才意识到问题的严重性。模型生成的代码是概率产物,它可能因为上下文理解偏差,生成出你完全不想执行的命令。

所以,凡是模型生成的代码或可能产生副作用的工具操作,都必须放进沙箱执行。沙箱的核心目标不是防黑客,而是防“失控”:防模型幻觉、防恶意 prompt 注入、防执行超时耗光资源。

3.2 常用沙箱方案横评

我试过几种沙箱方案,简单排个序供大家参考:

方案隔离强度启动速度适用场景
Docker 容器中等偏高几百毫秒到秒级大多数生产环境首选
子进程 + timeout低毫秒级只跑可信工具逻辑时用
gVisor / Firecracker高秒级多租户场景,安全要求极高
seccomp + namespace中毫秒级对性能敏感的内部工具

对于大多数业务团队,我推荐 Docker 容器方案。它生态成熟,镜像管理方便,docker compose 能一键编排多个服务,团队里没人不会。如果你只是想限制单个命令的超时,不处理文件系统隔离,那子进程加 timeout 就够了,别过度设计。

3.3 一个最小可用的沙箱执行方案

我目前在用的一个轻量方案,核心思路是“启动容器→执行命令→收集 stdout/stderr→超时强杀”。

docker run --rm \ --network none \ --memory 512m \ --cpus 1 \ -v /tmp/agent_workspace:/workspace:ro \ python:3.10-slim \ python /workspace/run_task.py

拆开解释一下这几个参数的意义:

  • --network none:禁止容器访问网络,防止模型生成的代码往外发数据。
  • --memory 512m 和 --cpus 1:限制资源,防止死循环吃满宿主机。
  • -v /tmp/agent_workspace:/workspace:ro:把宿主机的任务目录只读挂载进去,执行结果通过 stdout 或共享目录拿回来。
  • python:3.10-slim:预装好常用依赖,避免每次启动都要装包。

这套方案的隔离强度足够应对大部分内部 Agent 场景。如果硬要说缺点,就是每次启动容器有几百毫秒开销,但对于需要跑代码的任务,这点开销完全可接受。

3.4 超时、资源限额与文件隔离

沙箱执行还有一个隐藏的细节:超时判断。docker run 命令本身支持 --timeout 参数,但这个超时只对容器优雅退出有效,如果容器内的进程不响应终止信号,docker 也没办法。稳妥做法是在外层用 timeout 命令包裹:

timeout 10 docker run --rm ...

这个 timeout 10 的意思是:如果 docker run 在 10 秒内没退出,就发送 SIGTERM,再等几秒还不行就 SIGKILL。我建议两层超时都设:docker run --timeout 设置优雅退出时间,timeout 命令设置硬性上限,双保险。

文件隔离方面,一个常见问题是“容器写出来的文件归 root 所有”。Docker 容器默认以 root 运行,写进挂载目录的文件宿主机会显示为 root 创建,后续处理非常麻烦。解决方案是创建容器时指定 --user $(id -u):$(id -g),让容器内进程以当前用户权限运行。

4. 报错与重试:让错误变成下一次的上下文

4.1 错误类型决定重试策略

Agent 请求的报错五花八门,但归纳起来不外乎四类:

  • 模型返回格式异常:tool_calls 字段缺失、arguments 解析失败。
  • 工具执行报错:IndexError、KeyError、数据库连接失败、运行时异常。
  • 沙箱环境问题:容器启动失败、镜像拉取失败、内存不足。
  • API 层错误:超时、限流、网络抖动。

不同错误的重试策略完全不同。很多人一遇到报错就无脑重发整个请求,这是最浪费 token 的做法。格式异常可以让模型修正后重试;工具执行报错要把错误信息喂回去让模型调整;沙箱环境问题直接代码层重试,不用再花一次模型调用;API 限流要用指数退避。

4.2 带反馈重试:把 stderr 喂回给模型

这是 Agent 重试机制里最核心的一招,也是和传统程序重试最大的区别。传统程序报错后重试,往往是重复执行同一段逻辑;Agent 报错后重试,是把错误信息作为“观察结果”送回去,让模型重新推理。

具体做法是,当工具执行抛出异常时,捕获异常并构造一条 tool 消息:

{ "role": "tool", "tool_call_id": "call_abc123", "content": "执行出错:IndexError: list index out of range。当前可用数据的前100个字符为:..." }

然后把这条消息和原来的 assistant 消息一起追加进 messages,再发起第二轮请求。模型看到自己刚才的工具调用报错了,会尝试修正参数或换一种实现方式。这一招用在“模型写代码但代码跑不通”的场景里效果尤其明显。

有个值得注意的细节:错误信息里最好带上必要的上下文。比如 IndexError 发生的时候,给出当前列表的长度、索引值、附近的数据样貌,能帮模型更快定位问题。只甩一句“list index out of range”,模型大概率会瞎猜。

4.3 退避重试与重试风暴

代码层重试也有讲究,尤其是调用外部 API 的时候。如果接口返回 429 或 5xx,最简单的策略是固定间隔重试三次,但生产环境中这种策略很容易引发“重试风暴”:上游短暂故障时,所有请求同时撞上去,把服务彻底打崩。

我建议采用指数退避加抖动:

第一次重试等待 1s 第二次重试等待 2s 第三次重试等待 4s 每次加上 0~500ms 的随机抖动

抖动的意义是避免多个客户端在同一时刻同时重试。这个策略在调用大模型 API 和沙箱调度上都适用。

重试次数的上限也要设好。Agent 领域的经验值是重试 2~3 次就够,超过这个次数,模型自我修正的成功率断崖式下降,反而容易陷入“报错→修正→又报错”的循环,白白消耗大量 token。

4.4 记录与终止条件

每个 Agent 请求都应该有完整的追踪记录,至少包含四个字段:原始请求、模型回复(含 tool_calls)、工具执行日志(含报错)、最终答案。有了这条日志链,排查问题会轻松非常多。

终止条件也要明确写进代码。常见的终止条件有两种:达到最大工具调用轮数,或者模型给出的回复里不再包含 tool_calls,说明它准备直接回答了。两个条件哪个先满足就停。没有终止条件的 Agent 就是一个潜在的死循环炸弹,在沙箱环境里还好,在生产环境直接调用外部 API 的话,钱会烧得很快。

5. 常见问题与排查实录

5.1 arguments 不是合法 JSON

这是 tool_calls 解析阶段最高发的报错。现象是 JSON.parse 抛异常,或者解析出来了但缺字段。

我的排查套路是:先把原始 arguments 字符串打印出来人工看一眼。大多数情况下会发现问题出在模型多输出了注释、末尾多逗号、或者把 JSON 包在了 markdown 代码块里。处理办法是加一层“提取 JSON 片段”的预处理:如果字符串里出现 ```json 标记,先截取出代码块内容;如果大括号明显不平衡,尝试补全右括号。

这个处理逻辑建议放到公共工具函数里,因为几乎每个 Agent 项目都会遇到。

5.2 IndexError:列表越界的典型现场

热词里频繁出现“indexerror报错”,这其实是 Agent 生成代码执行时最常见的异常之一。原因是模型在不知道列表长度的情况下,经常想当然地访问 index 0 或 index 1,结果拿到的列表是空的。

我处理这类问题的心得是:在传给模型的上下文中,主动告知数据形状。比如“该工具返回一个数组,当前数组长度为 0”,模型下次就会先判空再访问。一味报错重试,模型修复的成功率并不高,因为信息不足。你给它一个索引,不如给它一张地图。

5.3 沙箱环境本身出错

有时报错不在 Agent 业务逻辑里,而是沙箱环境本身出问题。比如 docker compose up -d 启动失败、镜像拉取超时、容器启动时提示内存不足。

这类问题先别急着调整 Agent 代码,按环境问题排查:查镜像是否存在、磁盘空间、docker 服务状态、是否有同名容器冲突。我遇到过最气人的一次是磁盘被日志打满,容器创建直接失败,Agent 端反复报错重试,白白耗费几千 token,最后发现是宿主机磁盘满了。

建议给沙箱环境加健康检查机制:每次请求前检查一次宿主机可用内存和磁盘,低于阈值就先告警,别硬跑。

5.4 模型“幻觉调用”了不存在的工具

还有一种情况是模型调用了 tools 列表里根本没有的工具。这通常不是模型瞎编,而是上下文里出现了工具名干扰。比如用户的提问里提到“用 playwright 打开网页”,而你根本没注册这个工具,模型也可能生成一个 playwright 的 tool_call。

处理办法很直接:执行前校验 tool_call 的 function.name 是否在已注册的工具集合里。不在就返回错误消息“工具不存在,请选择以下工具之一:...”,让模型重新选择。这个校验一步都不能省,否则你的执行环境会收到未知指令。

5.5 调试利器:三段式请求日志

最后分享一个我每个 Agent 项目都会加的调试功能:三段式日志。每次请求都打印三段内容——请求段(messages 和 tools)、工具调用段(tool_calls 原样输出 + 工具执行结果)、报错段(异常堆栈和重试次数)。

这套日志的价值在于,你可以完整回放模型的每一次思考过程。问题出现时,不用靠猜,直接翻日志看是哪一环断掉了。我见过太多排查效率低的团队,问题明明出在工具参数解析,他们却去调重试策略,方向完全错了。有了三段式日志,十分钟就能定位根因。

我个人在实际操作中的体会是,Agent 开发里最花时间的不是写代码,而是和模型的“不确定性”做斗争。请求组装、tool_calls、沙箱、重试,每一环都是在降低不确定性。做得越多,系统越稳。最后再给一个建议:先别追求复杂的多 Agent 编排,把一个 Agent 请求的四个环节跑顺、日志打全、重试策略验证好,这个基本功比什么都值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询