AI Agent 工具参数传递:从原理到代码实现

0 阅读

工具参数传递为何容易出错

AI Agent 的核心能力在于"动手"——调用计算器查汇率、操作数据库跑查询、控制浏览器填表单。但大模型生成的参数经常对不上工具的输入格式:该传数字的地方给了字符串,必填字段漏了,或者干脆调了个不存在的工具。

问题根源在于 LLM 的输出是文本,而工具需要结构化数据。中间这层转换做得好不好,直接决定 Agent 是能用还是摆设。

两种主流实现模式

直接调用模式

最简单的做法是让模型输出 JSON,直接解析后调用对应函数。OpenAI 的 Function Calling 走的就是这条路。

class DirectAgent:
    def __init__(self, llm, tools):
        self.llm = llm
        self.tools = {t.name: t for t in tools}
    
    def run(self, task):
        # 构造带工具描述的提示
        tool_desc = "\n".join([f"- {name}: {t.description}" 
                              for name, t in self.tools.items()])
        
        prompt = f"""任务:{task}
可用工具:
{tool_desc}

请输出 JSON:{{"tool": "工具名", "params": {{参数}}}}"""
        
        response = self.llm.generate(prompt)
        call = json.loads(response)
        
        # 执行调用
        tool = self.tools[call["tool"]]
        return tool.execute(**call["params"])

优点是快,单次交互就能完成。缺点是容错差,模型一旦输出格式不对就崩,也没法处理多步骤任务。

ReAct 循环模式

ReAct(Reasoning + Acting)把任务拆成"思考→行动→观察"的循环,每轮只走一步,用观察结果修正下一步。

class ReActAgent:
    def __init__(self, llm, tools, max_iter=10):
        self.llm = llm
        self.tools = {t.name: t for t in tools}
        self.max_iter = max_iter
    
    def run(self, task):
        context = f"任务:{task}\n"
        
        for i in range(self.max_iter):
            # 思考
            thought = self.llm.generate(
                context + "\n请思考下一步,若完成请输出[FINAL]结果"
            )
            
            if "[FINAL]" in thought:
                return thought.split("[FINAL]")[-1].strip()
            
            # 解析行动
            action = self._parse_action(thought)
            if not action:
                context += f"\n思考:{thought}\n解析失败,请重新思考"
                continue
            
            # 执行并观察
            tool_name, params = action
            if tool_name not in self.tools:
                obs = f"错误:无工具'{tool_name}'"
            else:
                try:
                    obs = self.tools[tool_name].execute(**params)
                except TypeError as e:
                    obs = f"参数错误:{str(e)}"
                except Exception as e:
                    obs = f"执行失败:{str(e)}"
            
            context += f"\n思考:{thought}\n行动:{tool_name}({params})\n观察:{obs}"
        
        return "达到最大迭代次数"
    
    def _parse_action(self, text):
        # 从思考文本中提取工具调用
        # 实际可用正则或让模型输出结构化格式
        import re
        match = re.search(r'使用(\w+)\(([^)]+)\)', text)
        if match:
            name = match.group(1)
            params = eval(f"dict({match.group(2)})")  # 简化处理
            return name, params
        return None

ReAct 的优势是容错性强:参数错了可以重试,工具挂了可以换别的,复杂任务能拆多步。代价是延迟高、token 消耗大,一轮任务可能调十几次模型。

参数校验的关键细节

无论哪种模式,参数校验都是必做的。LLM 生成的参数常见毛病:

  • 类型漂移:把 "42" 当数字用,或把列表序列化成字符串
  • 字段缺失:漏传必填参数,或传了工具不认识的字段
  • 范围越界:日期格式不对,数值超范围

一个实用的做法是给每个工具配 Pydantic 模型:

from pydantic import BaseModel, Field, validator

class SearchParams(BaseModel):
    query: str = Field(..., min_length=1, max_length=200)
    limit: int = Field(default=10, ge=1, le=100)
    
    @validator('query')
    def no_sql_injection(cls, v):
        if ';' in v or '--' in v:
            raise ValueError('非法字符')
        return v

class SearchTool:
    name = "search"
    description = "搜索信息"
    params_schema = SearchParams
    
    def execute(self, **kwargs):
        # 自动校验
        validated = self.params_schema(**kwargs)
        # 实际调用...
        return f"搜索'{validated.query}'的结果"

Agent 执行前先用 params_schema 校验,不通过就返回错误让模型重试,比等工具报错再处理更干净。

成本控制与延迟优化

ReAct 模式好用,但账单容易失控。几个省钱的实际做法:

模型分级:简单步骤用小模型(如 GPT-3.5),只有复杂推理才上 GPT-4。规划阶段用便宜的,执行阶段按需升级。

缓存思考结果:同类任务的思考模式高度重复。把常见任务的"思考→行动"链缓存下来,下次直接复用,减少重复推理。

限制迭代次数:设硬上限(如 10 轮),同时监控单步 token 消耗,异常时提前终止。

批量工具调用:如果工具支持,把多个独立调用合并成一批。比如要查三个城市的天气,打包一次请求而不是拆三轮。

一个完整的文档处理示例

把上面这些拼起来,看一个实际能跑的文档处理 Agent:

import os
from pathlib import Path

class FileReaderTool:
    name = "read_file"
    description = "读取文件内容"
    
    def execute(self, path: str, max_chars: int = 5000):
        try:
            content = Path(path).read_text(encoding='utf-8')
            return content[:max_chars] + "..." if len(content) > max_chars else content
        except Exception as e:
            return f"读取失败:{e}"

class ClassifyTool:
    name = "classify"
    description = "按内容分类文档"
    
    def __init__(self, llm):
        self.llm = llm
    
    def execute(self, content: str):
        prompt = f"将以下内容分类为:技术文档/商务合同/会议纪要/其他\n\n{content[:1000]}"
        return self.llm.generate(prompt).strip()

class DocumentAgent:
    def __init__(self, llm):
        self.llm = llm
        self.tools = {
            "read_file": FileReaderTool(),
            "classify": ClassifyTool(llm),
        }
        self.agent = ReActAgent(llm, list(self.tools.values()))
    
    def process_folder(self, folder: str):
        results = []
        for file in Path(folder).glob("*.txt"):
            # 构造任务描述
            task = f"读取文件 {file},分类其内容,返回类别和摘要"
            
            # 运行 Agent
            result = self.agent.run(task)
            results.append({
                "file": file.name,
                "result": result
            })
        return results

# 使用
# agent = DocumentAgent(my_llm)
# print(agent.process_folder("./documents"))

这个例子展示了工具间的依赖(分类工具内部也用 LLM),以及如何用 ReAct 把多个工具调用串成工作流。

常见坑与应对

模型幻觉工具:LLM 有时会坚持调用不存在的工具。应对方法是在提示里明确列出可用工具,并加兜底回复:"若无合适工具,请说明无法完成"。

参数过度生成:模型喜欢给工具塞额外参数。用 Pydantic 的 extra='forbid' 严格拒绝未定义字段,逼模型精简。

循环陷阱:ReAct 可能陷入重复调用同一工具的循环。在上下文里记录已尝试的行动,提示模型"不要重复上一步"。

敏感操作确认:涉及删除、转账等操作时,Agent 应暂停并请求人工确认,而不是直接执行。这需要在工具层加权限标记,Agent 层识别后触发中断。

选型建议

场景 推荐模式 理由
单步查询、格式固定 直接调用 快、省 token
多步骤、需容错 ReAct 灵活、可恢复
高并发、成本敏感 直接调用 + 缓存 可控成本
复杂决策、需解释 ReAct + 思考链 可审计、可调试

工具参数传递没有银弹。理解两种模式的 trade-off,根据任务复杂度、延迟要求和预算选合适的方案,比盲目追新更实在。