别人看项目名总以为这是个带界面的后台系统,其实不是。Agent-Reach是我最近半年一直在打磨的一套Agent工程化框架,核心解决一件事:让大模型真正“够得着”业务系统。你可以把它理解成给LLM装了一双可以操作真实工具的手——查数据库、提单、改状态、发通知,全部通过自然语言对话完成。
这半年里我把这套东西从零搭起来,接进过客服工单、CRM客户管理、内部数据报表三个真实场景,中间踩过的坑、推翻过的设计、最终沉淀下来的方案,都整理在下面。不管是刚接触Agent开发,还是已经在做类似工具调用项目,这份内容应该都能帮你少走不少弯路。
1. 项目定位与核心设计思路
1.1 一句话讲清Agent-Reach在做什么
如果用一句话概括:Agent-Reach是一个以“工具触达”为核心目标的Agent运行框架,它让大模型在对话中自动决策“该调用哪个API、传入什么参数、拿结果做什么”,并在多个工具之间自主编排执行顺序,最终完成一个真实业务动作。
听起来可能有点像Function Calling的封装,实际上远不止。单纯让模型输出一个JSON调用指令只是起点,真正复杂的是调用完之后的循环:结果怎么回填给模型、多步任务怎么编排队列、失败之后要不要重试、上下文怎么控制不膨胀。Agent-Reach把这套完整的循环固化成了可复用的运行时,而不是每次从零写一遍。
我在设计时参考了LangChain和AutoGPT的思路,但最终没有直接采用,而是自己实现了精简版。原因后面会细说,这里先记住结论:框架越重,调试越难,真实业务环境里你需要的是可控,而不是花哨。
1.2 默认Agent架构卡在“工具触达”上的三层问题
我复盘过市面上大多数Agent项目,它们demo跑得很漂亮,但一接真实系统就崩,基本都卡在三个位置。
第一层是模型层。模型输出的调用参数经常不按照你定义的格式来,多一个空格、少一个引号、字段名写错,直接导致JSON解析失败。这不是模型笨,而是你的工具定义本身不够“显眼”,提示词里没有把参数约束强调到位。
第二层是执行层。工具调用不是一次就能成功的,真实系统会有鉴权过期、限流、数据格式不匹配这些情况。Agent得有处理这些异常的能力,而不是第一次失败就抛异常。很多项目在这里选择了重试,但重试策略很粗暴,导致业务系统被重复写入了脏数据。
第三层是状态层。Agent在执行一个多步骤任务时(比如“查一下这个用户的上次工单,然后给他创建一个售后单”),中间结果要短暂保存、步骤之间的依赖关系要管理。没有状态管理,模型就会在第二步的时候忘记第一步查出来的客户ID到底是多少。
Agent-Reach的设计目标,就是针对这三层做工程化兜底。我在实际项目中验证过,这套设计能把Agent从“玩具”推向“能干活”的边界。
1.3 整体架构与一次完整调用链
先画一个底层的逻辑链路,帮助你理解整个框架是怎么串起来的。
用户输入一句自然语言,比如“帮我把2024-12-01之后未关闭的工单全部标记为已处理,并通知对应的负责人”。这个输入会先经过Agent运行时处理,核心链路如下:
- 输入送入大模型,模型基于当前会话和已注册的工具列表,判断是否调用工具。如果需要,则输出结构化的调用请求(工具名+参数)。
- 运行时解析这个请求,先做参数校验和格式清洗,再通过统一调用层分发到对应的业务方法。
- 业务方法执行完成后,返回一个标准化格式的结果(成功标志、数据载荷、耗时、错误信息)。
- 运行时把工具结果拼接成一条上下文消息,回传给模型。
- 模型读取工具结果,判断任务是否完成。如果没有,继续下一步工具调用或修改参数重新执行。
- 循环继续,直到模型输出最终的自然语言回答,或者达到预设的最大步数。
这套链路看起来不复杂,但每一步都有大量的细节要打磨。下面我按技术模块来拆解,说明每个部分为什么这么设计、踩过什么坑。
2. 关键技术选型与细节拆解
2.1 模型选择:Function Calling还是纯文本生成
当前主流大模型都支持原生的Function Calling(也有叫Tool Use的),这是Agent-Reach的首选通道。但要区分清楚:不是所有模型都支持这个能力,也不是所有场景都必须用它。
我在Agent-Reach里做了一个可切换的调用后端,默认走模型的Function Calling接口,同时保留一个纯文本解析兜底。为什么保留兜底?因为实际业务中可能因为某些原因只能用文本生成模型,或者部分模型官方函数调用不稳定。用纯文本生成时,我在提示词里要求模型严格输出一段JSON,然后通过正则方式做容错提取,再走同一套校验逻辑。
这里有一个非常重要的工程判断:不要把Agent和某一个具体模型绑死。我在项目早期绑定了一个特定模型,后续每次优化业务提示词都要跟着它的脾气调整,换模型等于重写一遍。后来把工具描述、参数校验、结果回填全部做成模型无关格式,大模型在Agent-Reach里变成可替换的组件,业务才能真正跑得稳。
2.2 工具注册与Function Schema设计
Agent-Reach里最核心的抽象是工具(Tool)。每个工具由三部分组成:名称、描述、参数Schema、执行函数。我用JSON Schema描述参数,因为这已经是大模型调用的事实标准。
@tool.register( name="query_ticket", description="根据工单ID或时间范围查询工单信息,支持筛选状态和负责人", parameters={ "type": "object", "properties": { "ticket_id": {"type": "string", "description": "工单编号,形如TK-20241201-001"}, "status": {"type": "string", "enum": ["open", "processing", "closed"], "description": "按状态筛选,可选"}, "limit": {"type": "integer", "description": "返回条数上限,默认10", "minimum": 1, "maximum": 50} }, "required": ["limit"] } ) def query_ticket(ticket_id=None, status=None, limit=10): # 这里执行真实业务查询 ...这个设计的精妙之处在于:工具描述决定了模型能不能决策正确。很多开发者工具名写得很随意,描述也是一句话带过,模型根本不知道该在什么时候调用。我的经验是,工具描述要回答三个问题:这个工具是干什么的、什么时候该用它、什么时候不该用它。
举个例子,“query_ticket”工具的描述里我会加一句“仅当用户明确询问工单详情或需要根据工单做后续操作时使用,不要主动调用”,效果立竿见影——误调用率下降了非常多。
关于参数Schema,一个容易被忽略的要点是required字段一定要严格。让模型自己决定哪些参数必填,这是灾难的开始。Agent-Reach的注册机制在解析参数时会做两层校验:第一层是Schema原生校验,第二层是业务校验(比如时间范围大小、状态值是否在枚举内),两层都通过才允许真正执行工具。
2.3 上下文与记忆管理:给Agent一张可随时查阅的工作台
大模型Agent的上下文窗口是稀缺资源。工具定义、历史对话、工具结果都要占token,如果不做控制,一个稍微复杂的任务就会把上下文撑爆。Agent-Reach的解决方案是“工作台记忆”机制。
所谓工作台,我把它设计成一个按任务隔离的键值存储。每个任务启动时生成一个task_id,所有工具执行过程中的关键字结果(比如查询出来的客户ID、创建成功的工单号)都会自动写入工作台。对话中模型不是必须把全部工具结果都记在上下文里,而是可以选择只把摘要放回去,甚至把原始结果放在工作台里、只给模型一个“已保存”的通知。
这样做有一个明显收益:上下文长度可控,模型不会因为塞进大段JSON而表现变差。代价是实现复杂度增加,你需要设计一套清晰的工作台API,并且在提示词里教会模型“何时读取工作台、何时写入工作台”。
我给Agent-Reach设定的规则是:工具返回结果超过某个长度阈值(比如超过500个字),不直接拼接进上下文,而是只回传一个简短摘要和保存位置。模型如果需要完整数据,可以再去调用一个内置的read_workspace工具。这相当于给模型配了一个外部硬盘,大脑只留索引。
2.4 工具调用循环与多重安全防线
模型的工具调用循环是整个Agent的心脏,也是风险最集中的区域。Agent-Reach在这个环节插了三道安全防线。
第一道是参数预校验。模型输出来的参数绝对不能直接执行。比如模型传了一个limit=-1,或者把客户手机号传进了工单号字段,这些要通过参数校验拦截下来,必要时返回给模型一个错误说明让它自己修正。我在实测中发现,模型在连续对话中有时会“惯性”带入上一轮的参数,这是预校验能拦截最多的问题类型。
第二道是操作确认。凡是修改类工具(创建、删除、更新),默认开启确认机制。Agent不会直接执行,而是先输出一个“操作确认卡”,包含工具名、参数说明、影响范围,等待用户确认后再执行。这块我经历过惨痛的教训:一次评估时Agent把一批测试数据的状态全部改了,从那以后写操作不加确认我就睡不着觉。
第三道是执行超时和步数限制。Agent-Reach为每个任务设置了最大工具调用次数(默认10次)和单次工具执行超时(默认15秒),超限就强制终止并生成一个“任务未完成”的中间态,避免Agent卡死在循环里。
3. 从零到一:亲手搭一个最小可用的Agent-Reach
这一节我把整个项目最小可复现的核心代码拆出来,按照我实际搭建的顺序来讲,方便你照着做一遍。
3.1 确定最小项目骨架
我先说明技术选型:语言用Python 3.10+,模型调用用OpenAI兼容接口(可以适配多个服务商),框架不依赖重量级第三方库,核心只用pydantic做参数校验和requests做HTTP请求。整个最小项目包含五个文件:
agent_reach/ ├── __init__.py ├── core.py # Agent运行时核心 ├── tools.py # 工具注册与执行 ├── memory.py # 工作台记忆 ├── config.py # 模型配置不引入外部Agent框架的原因很简单:可控性第一。当你依赖别人的框架时,出了问题你要去读别人的源码,而自己维护核心逻辑,出问题你只需要看自己的代码。
3.2 核心运行时代码与解释
先看基础的工具注册类,它是整个框架的基石。
# tools.py import json import inspect from typing import Callable, Dict, Any class ToolRegistry: def __init__(self): self._tools: Dict[str, Dict[str, Any]] = {} def register(self, name: str, description: str, parameters: dict): def decorator(func: Callable): self._tools[name] = { "name": name, "description": description, "parameters": parameters, "func": func, } return func return decorator def get_schema(self) -> list[dict]: # 生成给模型看的工具定义列表 schemas = [] for name, tool in self._tools.items(): schemas.append({ "type": "function", "function": { "name": name, "description": tool["description"], "parameters": tool["parameters"], } }) return schemas def execute(self, name: str, arguments: dict) -> dict: tool = self._tools.get(name) if not tool: return {"ok": False, "error": f"tool {name} not found"} try: # pydantic校验放在外层,这里专注执行 result = tool["func"](**arguments) return {"ok": True, "data": result} except Exception as e: return {"ok": False, "error": str(e)} registry = ToolRegistry()接下来注册一个查询工单的示例工具,方便后续跑通整个流程。
# 在tools.py中继续注册 @registry.register( name="query_ticket", description="按ID或状态查询工单信息。仅当用户明确要求查看工单时使用。", parameters={ "type": "object", "properties": { "ticket_id": {"type": "string", "description": "工单编号"}, "status": {"type": "string", "enum": ["open", "processing", "closed"]}, }, }, ) def query_ticket(ticket_id: str = None, status: str = None): # 模拟数据,真实场景替换为数据库查询 mock_data = [ {"id": "TK-001", "status": "open", "title": "登录报错"}, {"id": "TK-002", "status": "processing", "title": "支付失败"}, ] result = mock_data if ticket_id: result = [t for t in result if t["id"] == ticket_id] if status: result = [t for t in result if t["status"] == status] return result然后是核心的Agent调度循环。在这个循环里,模型输出被解析成工具调用请求,执行结果回填后再次送入模型,直到满足终止条件。
# core.py import json import time from .tools import registry class AgentCore: def __init__(self, model_client, max_steps=10): self.model_client = model_client self.max_steps = max_steps self.messages = [] # 初始化系统提示词,强调工作台用法 self.messages.append({ "role": "system", "content": ( "你是Agent-Reach运行助手。你可以调用用户提供的工具来完成任务。" "如果工具返回异常,请根据错误信息调整参数后重试。" "完成任务后,请用自然语言回复用户。" ) }) def _call_model(self, tool_schemas): resp = self.model_client.chat( messages=self.messages, tools=tool_schemas, tool_choice="auto", ) return resp def run(self, user_input: str): self.messages.append({"role": "user", "content": user_input}) tool_schemas = registry.get_schema() for step in range(self.max_steps): resp = self._call_model(tool_schemas) msg = resp["choices"][0]["message"] # 1. 模型没有调用工具,视为直接回复,结束循环 if not msg.get("tool_calls"): self.messages.append(msg) return msg["content"] # 2. 有工具调用,执行并把结果作为tool消息追加 self.messages.append(msg) for tc in msg["tool_calls"]: fn_name = tc["function"]["name"] fn_args = json.loads(tc["function"]["arguments"]) exec_result = registry.execute(fn_name, fn_args) self.messages.append({ "role": "tool", "tool_call_id": tc["id"], "content": json.dumps(exec_result, ensure_ascii=False), }) return "已达到最大步数限制,任务未能完成。" def add_workspace_message(self, content: str): # 工作台机制的简化版:允许开发者注入额外上下文 self.messages.append({"role": "system", "content": content})上述代码是Agent-Reach的最简核。实际生产版本还多了工作台、确认机制、日志跟踪、参数清洗等模块,但骨架就是这一段循环。搞清楚这段逻辑,你就理解了Agent的本质:一个“模型-工具-结果”反复循环的自动决策程序。
3.3 模型接入与参数洞察
现在接入一个真实模型客户端。以OpenAI兼容接口为例,我把配置独立出来,方便切换模型服务商。
# config.py import os MODEL_CONFIG = { "base_url": os.getenv("LLM_BASE_URL", "https://api.example.com/v1"), "api_key": os.getenv("LLM_API_KEY", ""), "model": os.getenv("LLM_MODEL", "gpt-4o-mini"), } # 一个简单封装 import requests class ChatClient: def __init__(self, config): self.config = config def chat(self, messages, tools=None, tool_choice="auto"): payload = { "model": self.config["model"], "messages": messages, "tools": tools, "tool_choice": tool_choice, "temperature": 0.2, # 关键参数:工具调度场景temperature要低 } resp = requests.post( f"{self.config['base_url']}/chat/completions", headers={"Authorization": f"Bearer {self.config['api_key']}"}, json=payload, timeout=30, ) resp.raise_for_status() return resp.json()这里要专门说一下温度参数。生成类任务喜欢把temperature设置得偏高(比如0.7)让回复更有创造性,但Agent工具调度场景恰恰相反。我在反复调试后把temperature固定在0.2以下,这能显著提升工具调用参数的稳定性。温度高的时候模型会“即兴发挥”,把工单状态枚举值写成“处理中”而不是“processing”,导致校验失败。同样的道理,top_p也建议设置在0.5左右,别用默认的1.0。
3.4 实测跑通一个完整任务
我直接用上面这套代码跑一个真实例子。启动Agent后输入:“最近有哪些open状态的工单?帮我看看TK-001的详情。”
执行过程如下:
第一步,用户输入进入循环。模型收到工具定义和用户问题,判断需要调用query_ticket,输出参数为{"status": "open"}。
第二步,运行时执行该工具,返回两条模拟工单数据。工具结果拼接成JSON回传给模型。
第三步,模型看到返回数据中有TK-001,判断需要查询详情,再次调用query_ticket,参数为{"ticket_id": "TK-001"}。
第四步,运行时执行查询,返回TK-001的详细数据。
第五步,模型用自然语言整理结果输出给用户:“当前有2个open状态的工单:TK-001(登录报错)和TK-002(支付失败)。TK-001的详情为:……”
整个流程循环了三次工具调用,全部自动完成,没有人工干预。实际线上使用时,这个能力可以用来做工单分类、自动读取客户信息、定时生成报表摘要,甚至串联多个业务系统完成跨模块操作。
4. 实战中的问题排查与避坑清单
这一节是我最想写的部分。框架代码谁都能照着敲,但那些在真实业务里暴露出来的坑,只有跑过生产环境才写得出来。我按照“症状-原因-解法”的格式整理成速查表,方便你对照排查。
4.1 工具调用不触发或触发错乱
症状:模型面对明确需要查询的问题,直接回答“无法获取数据”,或者把A工具的字段套到B工具上。
原因分析:大部分情况下是工具描述写得不够准确、不够“显眼”。模型的注意力机制会对描述中的关键词做匹配,如果你的描述过于模糊,或者多个工具的描述里出现大量重叠词汇,模型就会犹豫甚至选错。还有一个常见原因是模型版本本身对Function Calling支持不够好,输出的tool_calls字段经常为空。
解法:第一,把每个工具的描述写成“何时用+何时不用”的完整格式,例如“查询工单,当用户提到工单、工单号、工单列表时使用。不要用于创建或修改工单”。第二,减少同一批次注册的工具数量,把业务拆成更细粒度的子Agent,每个子Agent只暴露5个以内的工具。实测下来,工具数量越多,模型选错的概率越高,5个以内准确率最高。第三,如果换了模型后触发率骤降,优先检查模型版本是否支持新版的tools参数格式,必要时回退到functions参数兼容模式。
4.2 模型输出参数不合法导致执行失败
症状:报错信息显示json.loads解析失败,或者参数校验不通过。
原因分析:模型生成的arguments有时候是一个被截断的JSON片段,有时候混入了markdown代码块标记,有时候字符串里带了没法转义的引号。这些问题在长对话里尤其明显,因为早期对话中的格式错误会被模型“学习”并放大。
解法:我在Agent-Reach里实现了一个参数清洗函数,专门处理常见的脏数据:先剥离代码块标记,再用宽松的JSON修复策略(尝试补全缺失花括号、把单引号换成双引号),最后才交给pydantic。另一个有效手段是给模型一个“错误反馈”闭环:当参数校验失败时,不直接终止,而是把错误信息返回给模型,让它自己修正后重新调用。这个机制我在实际使用中非常有效,模型的自我纠错能力远比想象中强。
def clean_arguments(raw: str) -> dict: raw = raw.strip() # 剥离可能的markdown代码块 if raw.startswith("```") and raw.endswith("```"): raw = raw.split("\n", 1)[1].rsplit("\n", 1)[0] # 尝试标准解析 try: return json.loads(raw) except json.JSONDecodeError: pass # 补全缺失的花括号 if not raw.rstrip().endswith("}"): raw = raw.rstrip() + "}" try: return json.loads(raw) except json.JSONDecodeError: # 最终兜底:尝试用eval的替代方案解析python格式dict import ast return ast.literal_eval(raw)4.3 Agent陷入死循环或者重复执行同一工具
症状:日志里看到同一个工具被调用了几十次,参数略有变化但实质上是在循环做同一件事,直到步数上限被强制终止。
原因分析:最常见的原因是工具返回的结果没有给模型提供有效的“结束信号”。比如查询工具返回了一个空列表,模型不知道空列表代表“没有数据,应该停止”,反而以为是自己参数不对,反复换参数重试。另一个原因是工具执行产生了副作用(比如创建了记录),模型基于这个记录又创建新的记录,形成逻辑上的自激循环。
解法:第一,所有工具返回的结果必须带上状态和语义判断。查询类工具如果查不到数据,返回格式应该是{"ok": true, "data": [], "hint": "未找到匹配记录"},给模型明确的停止信号。第二,Agent-Reach在运行时内置了重复检测:同一个工具在连续N步内被调用超过阈值,且参数相似度高于设定值,直接中断并让模型给出阶段总结,由用户决定下一步。第三,写操作工具一定要有幂等设计,创建类工具返回已有的标识而不是每次新建。
4.4 上下文膨胀与成本失控
症状:单次任务的token消耗是预期的5倍以上,响应越来越慢,甚至接近模型上下文上限。
原因分析:工具结果太大直接被拼进了messages,多轮循环后历史消息越积越多。尤其是查询类工具返回大JSON数组的场景,一次查询就把几千个token吃掉了。
解法:Agent-Reach的工作台机制是主要对策。我规定任何工具结果超过500字符就必须走摘要路径,完整数据写入工作台存储,模型按需读取。另一个是定期压缩历史消息:当历史对话超过一定长度后,把前几轮服务商对话总结成一段摘要,替换原文。这个操作会损失一些细节,但在低成本模型上完全值得。
下表是我整理的几个高频问题速查:
| 症状 | 根因 | 解决手段 |
|---|---|---|
| 模型不调用工具直接回答 | 工具描述歧义或不匹配 | 重写描述为“何时用+何时不用”格式 |
| 参数解析报错 | 模型输出脏JSON | 增加清洗函数和错误回环 |
| 同一个工具反复调用 | 缺少停止信号 | 返回结果中添加提示语义 |
| 任务token消耗暴涨 | 工具结果直接进上下文 | 接入工作台摘要机制 |
| 写操作产生脏数据 | 缺少确认机制 | 强制开启写操作确认卡 |
4.5 长会话下的记忆漂移问题
这个坑我放在最后,因为它最隐蔽。当Agent和用户来回对话超过20轮,模型会逐渐“忘记”工具调用的规则,出现前后矛盾的行为。比如最开始它知道查询类的工具不能动数据,聊着聊着突然把一个查询操作写成了更新操作。
原因在于长会话的上下文里,早期系统提示词的重要程度被稀释了。解法是我在Agent-Reach里增加了一个关键机制:每轮工具调用前都向模型注入一次精简版的运行规则提醒。这段提醒独立于对话历史,并且在每个循环里都出现,相当于给模型一个持续生效的“工作准则”锚点。这个方法比单纯依赖系统提示词要可靠得多。
RUNNING_RULES = ( "【运行规则】\n" "1. 只使用以下工具完成任务,不要臆造工具。\n" "2. 工具参数必须使用英文枚举值,不要翻译成中文。\n" "3. 查询操作不允许修改任何数据。\n" "4. 当任务已完成或无法继续时,用自然语言回复用户并停止调用工具。\n" )每轮循环开始时,把这段规则作为system消息插入到当前messages中。实测消息总数不变的情况下,长会话中的错误调用率下降了接近一半,效果非常显著。
5. 我还想说的几个设计补充
Agent-Reach早期还有过一些走弯路的设计,比如试图让模型直接执行SQL查询语句、试图通过正则解析复杂嵌套参数、试图不加限制地让Agent访问所有工具。这些方向表面上看起来更“智能”,实际上都牺牲了稳定性和安全性。后来我做的每一次改动都在往“模型能力边界外移”的方向收:模型只负责决策和编排,真实执行永远走受控的业务代码路径。
关于工具调用的耗时有句体会:单次工具调用通常一秒钟以内,但一个复杂Agent任务的整体耗时会远大于各步骤之和,因为模型每一步都要重新读取上下文并思考。如果你的任务对实时性要求很高,就需要考虑把多步骤拆成流水线模板,而不是完全交给模型自由发挥。这也是为什么Agent-Reach在后期引入了“技能模板”的概念——把高频任务链(比如“查客户-查历史工单-生成处理建议”)固化成可复用的执行模板,模型遇到匹配场景时直接调用模板流程,而不是一步步重新决策。
6. 结尾:一点实用的经验总结
如果你现在准备着手做类似的Agent项目,我最后的建议是:先做一个只暴露两个工具的最小Demo跑通业务闭环,再逐步增加工具和场景。工具数量一多,调试难度指数级上升,不要一上来就追求全能Agent。
另外,一定要重视日志。Agent-Reach的每一次工具调用、参数、结果、耗时我都记了结构化日志。线上排障时,这些日志能直接定位到是哪一轮决策出了问题,省下的时间远超记录成本。
我自己的体会是,Agent项目最核心的难点从来不是“让模型聪明起来”,而是“让模型在约束内稳定地完成任务”。Agent-Reach整个项目的迭代,都是在朝这个方向努力。希望这篇文章的内容能给你的Agent工程化之路省下几个月的试错时间。