7.6 结果解析与反馈:工具返回结果融入 Agent 决策
上一节(7.5 节)我们讨论了工具调用中的权限与安全问题——如何限制工具的作用范围、如何做沙箱隔离、如何审计每一次调用。有了这些安全护栏,Agent 就可以在"不越界"的前提下大胆使用工具。然而,工具执行完毕并不意味着任务结束:Agent 还需要读懂工具返回的结果,判断它是成功还是失败、信息是充足还是残缺,并据此决定下一步走向。本节就聚焦这条链条的最后一环——结果解析与反馈。
本节也是第 7 章"工具调用"的收官。读完后,我们将自然过渡到第 8 章——Agent 框架,看看如何把协议层、工具层、安全层、结果反馈层全部组装成一个完整的、可落地的智能体。
7.6.1 从"工具返回"到"Agent 决策":翻译官整理情报
理解结果解析最直观的方式,是把它想象成一位翻译官整理情报的过程:
前线侦察兵(工具) -> 带回原始情报 -> 翻译官(解析器) -> 情报摘要 -> 司令官(LLM) -> 下一步行动- 侦察兵(工具):执行任务后带回原始材料,可能是一份满篇 JSON 的天气报文,也可能是一篇上万字的网页正文,甚至是一句"请求超时"的报错。
- 翻译官(解析器):不能把这些原始材料直接甩给司令官。他要做三件事——提炼(找出关键字段)、裁剪(砍掉冗余)、翻译(转成司令官能读的格式)。如果情报本身有残缺(报错、空值),翻译官还要注明"这份情报有缺陷"。
- 司令官(LLM):拿到整理好的情报后,综合判断该不该回答用户、要不要再派一次侦察、还是直接放弃并如实汇报。
这套"翻译官"机制看似简单,却是 Agent 区别于"单次函数调用"的关键:工具的结果不是终点,而是下一轮推理的输入。 解析做得不好,司令官就会被淹没在噪声里、做出错误判断;解析做得好,Agent 就能像经验丰富的参谋一样,把杂乱的数据转化为可执行的知识。
7.6.2 结果解析策略:五把"翻译"工具
不同的工具返回的数据形态各异,翻译官手里至少要备好五种策略:
| 策略 | 适用场景 | 做法 | 示例 |
|---|---|---|---|
| 原样透传 | 简单文本,长度可控 | 直接作为字符串返回 | 天气查询返回"晴,25°C" |
| JSON 解析 | 结构化数据 | 提取关键字段,重组为紧凑 JSON | API 返回嵌套 JSON |
| 摘要提取 | 结果过长 | 保留头尾,中间省略 | 网页抓取返回 5000 字 |
| 错误转换 | 异常返回 | 把异常对象转为可读错误描述 | 网络超时 → "查询失败,请稍后重试" |
| 富文本渲染 | 面向前端展示 | 保留 Markdown 表格/代码块格式 | 数据库查询结果渲染为表格 |
这五种策略不是互斥的,常常组合使用:比如一次返回的是大 JSON,就先做"JSON 解析"提取关键字段,再对某个长字段做"摘要提取"。
下面用一个统一的解析函数把前四种策略串起来:
import json
def parse_tool_result(result, max_tokens=2000):
"""
解析并裁剪工具返回结果。
参数:
result: 工具的原始返回值,可能是 dict / str / 其他类型
max_tokens: 期望截断后的大致 token 上限(按 4 字符≈1 token 粗估)
返回:
整理后的字符串,可直接塞进对话上下文
"""
# ---------- 情况一:返回值是字典(通常来自 JSON 接口) ----------
if isinstance(result, dict):
# 如果字典里带 error 字段,说明工具自己报告了失败
if 'error' in result:
# 走"错误转换"策略:把结构化错误变成一句话
return f"❌ 工具执行失败: {result['error']}"
# 正常的 JSON 结果:重新序列化为紧凑、可读的格式
# ensure_ascii=False 保证中文不被转义,indent=2 方便 LLM 阅读
return json.dumps(result, ensure_ascii=False, indent=2)
# ---------- 情况二:返回值是纯文本 ----------
if isinstance(result, str):
# 估算字符上限:max_tokens * 4 是粗略的 token→字符换算
char_limit = max_tokens * 4
if len(result) > char_limit:
# 走"摘要提取"策略:保留开头(往往含最关键信息)
head = result[:max_tokens * 2]
# 保留结尾(往往含总结、时间戳等)
tail = result[-500:]
# 中间用省略号标注被裁掉的长度
omitted = len(result) - max_tokens * 2 - 500
return f"{head}\n\n... (省略 {omitted} 字符) ...\n\n{tail}"
# 长度可控时走"原样透传"
return result
# ---------- 情况三:其他类型(数字、列表等)兜底转字符串 ----------
return str(result)解析函数的逻辑可以对照"翻译官"的三步走来理解:遇到字典就提炼字段、遇到长文就裁剪、遇到错误就标注。
7.6.3 结果过长的处理:给情报"做摘要"
当工具返回一篇万字长文时,如果原样塞进上下文,既浪费 token 又可能让 LLM 抓不住重点。上一节的 parse_tool_result 已经展示了"保留头尾"的朴素做法,下面给出一个更完善的截断器——它额外区分了"有结构"和"无结构"两种长文本:
def smart_truncate(text, max_chars=8000, keep_head=4000, keep_tail=2000):
"""
智能截断长文本。
参数:
text: 原始文本
max_chars: 触发截断的字符阈值
keep_head: 保留的开头字符数
keep_tail: 保留的结尾字符数
返回:
截断后的文本,中间用省略标记
"""
# 没超阈值,原样返回
if len(text) <= max_chars:
return text
# 尝试在段落边界切分,避免把一句话腰斩
head_end = keep_head
# 向后找到最近的换行符,做一次"对齐切分"
nl_pos = text.rfind('\n', 0, keep_head)
if nl_pos > keep_head * 0.5: # 确保不会切得太少
head_end = nl_pos
head = text[:head_end].strip()
tail = text[-keep_tail:].strip()
omitted = len(text) - head_end - keep_tail
return f"{head}\n\n... (已省略 {omitted} 字符) ...\n\n{tail}"设计要点:
- 优先在段落边界切分:避免一句话被截成两半,导致语义丢失。
- 头尾保留比例可调:开头通常含摘要、结尾通常含结论,中间往往是展开论述。
- 省略标记携带长度信息:让 LLM 知道"这里还有内容",在需要时可以再次调用工具获取完整版本。
7.6.4 失败处理:情报"残缺"怎么办
工具调用不可能永远成功。网络抖动、参数错误、权限不足……Agent 面对失败时的表现,才是真正体现"健壮性"的地方。先对失败做分类:
工具调用失败
├── 参数错误 -> 修正参数后重试(最常见,几乎一定能自愈)
├── 网络超时 -> 指数退避重试(最多 3 次,避免无限重试)
├── 权限不足 -> 提示用户授权(不要自己"猜"权限)
├── 服务不可用 -> 降级处理或跳过,换用备用工具
└── 未知错误 -> 记录日志,交给人工介入其中"指数退避重试"是最核心的模式。它的思想是:每次失败后等待时间翻倍,并加入一点随机抖动,避免多个 Agent 同时重试造成"惊群效应":
import asyncio
import random
async def call_tool_with_retry(tool_fn, *args, max_retries=3, **kwargs):
"""
带指数退避的工具调用。
参数:
tool_fn: 异步工具函数
max_retries: 最大重试次数
*args, **kwargs: 传给工具的参数
返回:
(result, error) 元组:成功时 error 为 None
"""
for attempt in range(max_retries):
try:
# 尝试执行工具
result = await tool_fn(*args, **kwargs)
return result, None # 成功,返回结果
except TimeoutError:
# 超时是最适合重试的失败类型
if attempt == max_retries - 1:
# 已经是最后一次尝试,不再等待,直接返回错误
return None, "请求超时,已重试 3 次仍未成功"
# 计算等待时间:2^attempt 秒 + [0,1) 的随机抖动
wait = (2 ** attempt) + random.uniform(0, 1)
await asyncio.sleep(wait)
except PermissionError:
# 权限错误不可重试——重试多少次都一样
return None, "权限不足,需要用户授权"
except Exception as e:
# 其他未知异常
if attempt == max_retries - 1:
return None, f"工具执行失败: {str(e)}"
# 未知错误也做一次退避等待
await asyncio.sleep(2 ** attempt)逐行要点:
for attempt in range(max_retries):用循环控制重试次数,最后一次不再 sleep。2 ** attempt:1 秒、2 秒、4 秒……指数增长,给服务端恢复时间。random.uniform(0, 1):抖动量,打散多个客户端的同步重试。PermissionError直接返回:这类错误重试无用,应立即上浮给用户。
7.6.5 结果融入 Agent 上下文:结构化"情报包"
解析完毕后,要把结果以结构化消息的形式注入对话历史。OpenAI、Anthropic 等主流协议都定义了 role: "tool" 这种消息角色,专门承载工具返回。一个规范的消息长这样:
def format_tool_message(tool_name, tool_call_id, result):
"""
把工具执行结果格式化为标准的 tool message。
参数:
tool_name: 工具名称,如 "web_search"
tool_call_id: 对应的调用 ID,用于把结果关联回发起的调用
result: ToolResult 对象(含 data / error / duration_ms 等字段)
返回:
符合 OpenAI / Anthropic 协议的 tool message dict
"""
return {
"role": "tool", # 消息角色固定为 "tool"
"tool_call_id": tool_call_id, # 关联到 LLM 发起的那次 tool_call
"name": tool_name, # 工具名称,方便 LLM 区分多个工具
"content": json.dumps({ # content 必须是字符串
# 状态标记:成功或失败,让 LLM 一眼看出结果是否可信
"status": "success" if result.error is None else "error",
# 实际数据:可能是文本、JSON 或 null
"result": result.data,
# 错误描述:成功时为 null
"error": result.error,
# 执行耗时:帮助 LLM 判断工具是否值得再次调用
"execution_time_ms": result.duration_ms
}, ensure_ascii=False) # 保留中文,不做 ASCII 转义
}为什么 content 要塞 JSON 字符串而不是直接放裸文本? 因为结构化字段让 LLM 更容易"理解"结果的性质——status 告诉它该不该信、error 告诉它问题在哪、duration_ms 暗示工具是否"昂贵"。这些都是裸文本难以自表达的元信息。
7.6.6 基于结果的决策:司令官的五种选择
拿到情报包后,LLM(司令官)根据"成功/失败"和"信息充足/不足"两个维度,会做出五种典型决策:
工具结果 -> Agent 分析
├── 成功 + 信息充足 -> 直接回答用户
├── 成功 + 信息不足 -> 调用更多工具补充
├── 失败 + 可重试 -> 修改参数或换工具重试
├── 失败 + 不可重试 -> 向用户解释并建议替代方案
└── 部分成功 -> 展示已有结果,说明缺失部分下面用一个决策评估函数把这五种情况落地为代码:
def evaluate_tool_result(result, user_query):
"""
评估工具结果是否满足用户需求,决定 Agent 的下一步动作。
返回值是一个 dict,action 字段标识动作类型:
- report_error 向用户报告错误
- retry_with_broader 换更宽泛的参数重试
- summarize_and_present 先摘要再展示
- present_directly 直接展示结果
"""
# ---------- 情况一:工具执行本身就失败了 ----------
if result.error:
return {
"action": "report_error",
"message": f"工具调用失败: {result.error}",
"suggestion": "请检查输入参数或稍后重试"
}
# ---------- 情况二:成功但结果为空 ----------
# 常见于搜索/查询类工具,可能需要放宽条件
if not result.data or result.data in ("[]", "{}"):
return {
"action": "retry_with_broader_query",
"message": "查询结果为空",
"suggestion": "尝试使用更宽泛的搜索条件"
}
# ---------- 情况三:结果过长,需要先做摘要 ----------
data_str = str(result.data)
if len(data_str) > 5000:
return {
"action": "summarize_and_present",
"message": "结果较多,已为您汇总",
"data": data_str[:3000] # 只截前 3000 字给 LLM
}
# ---------- 情况四:结果大小合适,直接呈现 ----------
return {
"action": "present_directly",
"message": "查询成功",
"data": result.data
}这段代码并不复杂,但它体现了一个重要原则:Agent 不应该把"决策"完全压给 LLM 的自由推理,而是用代码预先框定可选择的分支,让 LLM 只在有限选项里做判断。这正是后续第 8 章 Agent 框架要解决的核心问题之一。
7.6.7 实战:一个完整的工具执行器
把前面几节的策略——重试、截断、格式化、评估——组装成一个可复用的 ToolExecutor 类:
import json
import time
import asyncio
from dataclasses import dataclass, field
from typing import Any, Optional
@dataclass
class ToolResult:
"""工具执行结果的统一数据结构。"""
name: str # 工具名称
data: Any # 返回数据
error: Optional[str] = None # 错误描述,成功时为 None
duration_ms: float = 0 # 执行耗时(毫秒)
class ToolExecutor:
"""
统一的工具执行器:负责重试、截断、记录历史。
所有工具调用都应经由它执行,保证行为一致。
"""
def __init__(self, max_retries=3, result_max_chars=8000):
self.max_retries = max_retries # 每个工具的最大重试次数
self.result_max_chars = result_max_chars # 结果截断阈值
self.call_history = [] # 记录所有调用,供审计与统计
async def execute(self, tool_fn, tool_name, *args, **kwargs):
"""执行一个异步工具,返回 ToolResult。"""
start = time.time() # 记录开始时间
for attempt in range(self.max_retries):
try:
# 调用真正的工具函数
data = await tool_fn(*args, **kwargs)
# 计算耗时
duration = (time.time() - start) * 1000
# 封装结果,并对数据做截断
result = ToolResult(
name=tool_name,
data=self._truncate(data),
duration_ms=duration
)
self.call_history.append(result) # 写入历史
return result
except Exception as e:
# 最后一次尝试仍失败,封装错误结果
if attempt == self.max_retries - 1:
duration = (time.time() - start) * 1000
result = ToolResult(
name=tool_name,
data=None,
error=str(e),
duration_ms=duration
)
self.call_history.append(result)
return result
# 未到最后一次,指数退避等待
await asyncio.sleep(2 ** attempt)
def _truncate(self, data):
"""对返回数据做字符长度截断。"""
text = str(data)
if len(text) > self.result_max_chars:
return text[:self.result_max_chars] + \
f"\n... (截断 {len(text) - self.result_max_chars} 字符)"
return data
def get_summary(self):
"""返回本次会话的工具调用统计摘要。"""
total = len(self.call_history)
success = sum(1 for r in self.call_history if r.error is None)
return {
"total_calls": total,
"success_rate": success / max(total, 1),
"avg_duration_ms": sum(r.duration_ms for r in self.call_history) / max(total, 1)
}使用示例:
# 模拟一个搜索工具
async def mock_search(query):
await asyncio.sleep(0.5) # 模拟网络延迟
return f"搜索结果: {query} 相关内容"
# 创建执行器并运行
executor = ToolExecutor()
result = await executor.execute(mock_search, "search", "Python MCP")
print(f"工具: {result.name}")
print(f"成功: {result.error is None}")
print(f"耗时: {result.duration_ms:.0f}ms")
print(f"摘要: {executor.get_summary()}")输出:
工具: search
成功: True
耗时: 512ms
摘要: {'total_calls': 1, 'success_rate': 1.0, 'avg_duration_ms': 512.3}7.6.8 常见误区
在工程实践中,工具结果处理最容易踩的坑集中在以下几处:
原样把大 JSON 塞进上下文。 很多人认为"LLM 能处理 JSON",就直接把几千行的 API 返回丢进去。结果是 token 暴涨、注意力分散、关键信息反而被淹没。对策:始终经过
parse_tool_result提炼关键字段。失败后无脑重试。 对
PermissionError、ValueError(参数本身错)这类确定性失败也做重试,纯粹浪费时间和 token。对策:只对超时类瞬时故障做指数退避重试。重试不带抖动。 多个 Agent 实例同时失败、同时重试,会对下游服务形成"惊群"。对策:等待时间加
random.uniform。tool message 的 content 不带 status 字段。 只塞裸数据,LLM 无法快速判断结果是否可信。对策:统一用
{"status": ..., "result": ..., "error": ...}结构。截断只保留开头。 很多网页/API 的结论性信息在文末(时间戳、总计、建议),只留头会丢掉这些。对策:头尾各保留一段。
不记录调用历史。 出问题时无法复现。对策:用
ToolExecutor的call_history留痕,配合 7.5 节的审计日志使用。把"决策"完全交给 LLM 自由发挥。 不加代码分支约束,LLM 可能在失败后陷入"反复调用同一工具"的死循环。对策:像
evaluate_tool_result那样预先框定可选动作。
7.6.9 本节小结
本节围绕"工具返回后怎么办"这一命题,完成了从解析到决策的完整闭环:
| 环节 | 核心做法 | 关键词 |
|---|---|---|
| 结果解析 | 五策略:透传 / JSON / 摘要 / 错误 / 富文本 | 翻译官整理情报 |
| 结果截断 | 头尾保留 + 段落对齐切分 | smart_truncate |
| 失败处理 | 分类处置:瞬时故障指数退避,确定性错误立即上浮 | call_tool_with_retry |
| 结果融入 | role: "tool" 消息 + status/error/duration 元信息 | format_tool_message |
| 决策分支 | 成功/失败 × 充足/不足 → 五种动作 | evaluate_tool_result |
| 工程封装 | ToolExecutor 统一重试、截断、留痕 | ToolExecutor |
核心认知只有一句话:工具的结果不是终点,而是下一轮推理的燃料。 翻译官把情报整理得越清晰,司令官的判断就越准确。
下一章预告:第 7 章我们从协议层(7.1–7.2)、工具定义(7.3–7.4)、安全(7.5)一路讲到结果反馈(7.6),已经铺好了 Agent 的每一块"零件"。第 8 章将进入 Agent 框架——如何把这些零件组装成完整的、可复用的智能体架构。我们将从最简的单工具循环讲起,逐步扩展到多工具编排、ReAct 范式、以及主流开源框架的设计取舍。