Skip to content

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 解析结构化数据提取关键字段,重组为紧凑 JSONAPI 返回嵌套 JSON
摘要提取结果过长保留头尾,中间省略网页抓取返回 5000 字
错误转换异常返回把异常对象转为可读错误描述网络超时 → "查询失败,请稍后重试"
富文本渲染面向前端展示保留 Markdown 表格/代码块格式数据库查询结果渲染为表格

这五种策略不是互斥的,常常组合使用:比如一次返回的是大 JSON,就先做"JSON 解析"提取关键字段,再对某个长字段做"摘要提取"。

下面用一个统一的解析函数把前四种策略串起来:

python
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 已经展示了"保留头尾"的朴素做法,下面给出一个更完善的截断器——它额外区分了"有结构"和"无结构"两种长文本:

python
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}"

设计要点:

  1. 优先在段落边界切分:避免一句话被截成两半,导致语义丢失。
  2. 头尾保留比例可调:开头通常含摘要、结尾通常含结论,中间往往是展开论述。
  3. 省略标记携带长度信息:让 LLM 知道"这里还有内容",在需要时可以再次调用工具获取完整版本。

7.6.4 失败处理:情报"残缺"怎么办

工具调用不可能永远成功。网络抖动、参数错误、权限不足……Agent 面对失败时的表现,才是真正体现"健壮性"的地方。先对失败做分类:

工具调用失败
├── 参数错误      -> 修正参数后重试(最常见,几乎一定能自愈)
├── 网络超时      -> 指数退避重试(最多 3 次,避免无限重试)
├── 权限不足      -> 提示用户授权(不要自己"猜"权限)
├── 服务不可用    -> 降级处理或跳过,换用备用工具
└── 未知错误      -> 记录日志,交给人工介入

其中"指数退避重试"是最核心的模式。它的思想是:每次失败后等待时间翻倍,并加入一点随机抖动,避免多个 Agent 同时重试造成"惊群效应":

python
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" 这种消息角色,专门承载工具返回。一个规范的消息长这样:

python
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 分析
├── 成功 + 信息充足   -> 直接回答用户
├── 成功 + 信息不足   -> 调用更多工具补充
├── 失败 + 可重试     -> 修改参数或换工具重试
├── 失败 + 不可重试   -> 向用户解释并建议替代方案
└── 部分成功         -> 展示已有结果,说明缺失部分

下面用一个决策评估函数把这五种情况落地为代码:

python
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 类:

python
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)
        }

使用示例:

python
# 模拟一个搜索工具
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 常见误区

在工程实践中,工具结果处理最容易踩的坑集中在以下几处:

  1. 原样把大 JSON 塞进上下文。 很多人认为"LLM 能处理 JSON",就直接把几千行的 API 返回丢进去。结果是 token 暴涨、注意力分散、关键信息反而被淹没。对策:始终经过 parse_tool_result 提炼关键字段。

  2. 失败后无脑重试。PermissionErrorValueError(参数本身错)这类确定性失败也做重试,纯粹浪费时间和 token。对策:只对超时类瞬时故障做指数退避重试。

  3. 重试不带抖动。 多个 Agent 实例同时失败、同时重试,会对下游服务形成"惊群"。对策:等待时间加 random.uniform

  4. tool message 的 content 不带 status 字段。 只塞裸数据,LLM 无法快速判断结果是否可信。对策:统一用 {"status": ..., "result": ..., "error": ...} 结构。

  5. 截断只保留开头。 很多网页/API 的结论性信息在文末(时间戳、总计、建议),只留头会丢掉这些。对策:头尾各保留一段。

  6. 不记录调用历史。 出问题时无法复现。对策:用 ToolExecutorcall_history 留痕,配合 7.5 节的审计日志使用。

  7. 把"决策"完全交给 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 范式、以及主流开源框架的设计取舍。