1. 大模型Agent到底是个什么东西
先把概念说清楚,不然后面全是空中楼阁。大模型Agent,说白了就是让大模型从“你问我答的聊天框”变成“能自己动手干活的执行者”。普通的大模型调用是这样的:你发一段提示词,它回一段文本,结束。而Agent是在这个基础上加了三样东西——规划能力、工具调用能力、记忆能力。它接到一个任务后,会自己拆解步骤、决定用哪个工具、执行、看结果、再决定下一步,直到任务完成。
我举个生活化的例子。普通大模型像一个博学的顾问,你问他“帮我订一张明天去北京的票”,他会告诉你“你可以去某平台搜索,选择合适车次”。而Agent像一个助理,你说同样的话,他会直接打开购票接口、查余票、比价格、下单、把订单截图发给你。区别就在于Agent能操作外部世界,而聊天模型只能输出文字。
那为什么现在Agent这么火?核心原因是三个条件同时成熟了。第一,大模型的推理能力上来了,能稳定做多步规划;第二,Function Calling(工具调用)成了主流模型的标配能力,模型可以输出结构化的调用请求;第三,上下文长度大幅提升,从早期的4K到现在的128K甚至更长,Agent能记住的中间状态变多了。这三件事凑齐,Agent才从论文里的概念变成了能跑起来的产品。
这篇文章适合谁看?如果你有基本的编程能力,了解API调用是怎么回事,想从零搭一个能跑起来的Agent,那这篇就是写给你的。我会从架构设计讲到代码落地,把每一步的“为什么”都讲透,而不是只丢一段代码让你抄。如果你完全没写过代码,也能看懂前面的原理部分,知道Agent大概是怎么运转的。
提示:本文涉及的代码示例以Python为主,因为当前Agent生态里Python的库最成熟。如果你用其他语言,思路完全一致,只是SDK不同。
2. Agent的核心架构拆解与方案选型
2.1 一个Agent最少需要哪几个模块
很多人一上来就去看LangChain、AutoGPT这些框架,结果被一堆抽象概念绕晕。我的建议是先把Agent拆到最简,理解每个模块的职责,再去用框架。一个能干活的最小Agent,核心就四个部分:
- 大脑(LLM):负责推理和决策,是整个Agent的核心。它接收当前状态,输出下一步该做什么。
- 工具集(Tools):Agent能调用的外部能力,比如搜索、计算、读写文件、调用API。每个工具都有明确的名称、描述和参数定义。
- 记忆(Memory):短期记忆保存当前任务的对话历史,长期记忆保存跨会话的知识。没有记忆的Agent每次都是失忆状态,做不了多步任务。
- 执行循环(Loop):把上面三个串起来的控制流。典型流程是:观察当前状态→LLM推理→输出动作→执行动作→把结果写回记忆→再推理,直到任务完成或达到最大步数。
这四个模块里,执行循环是最容易被忽视但最关键的。很多新手写的Agent跑几轮就死循环了,或者提前终止,问题基本都出在循环的退出条件设计上。
2.2 为什么我建议从裸写开始而不是直接上框架
现在Agent框架很多,LangChain、LlamaIndex、AutoGen、CrewAI各有各的定位。但我强烈建议你第一个Agent用手写循环的方式实现,不要一上来就用框架。原因有三个。
第一,框架屏蔽了太多细节。你用LangChain的AgentExecutor,几行代码就能跑起来,但你不知道它内部是怎么组织提示词的、怎么解析工具调用的、怎么处理解析失败的。一旦出问题,你完全不知道从哪查。
第二,框架的抽象层会限制你的理解。Agent的核心逻辑其实很简单,就是“提示词工程+循环+工具调用”。你手写一遍,两个小时就能搞明白。之后再用框架,你是在用它的工程化能力,而不是在猜它的黑盒。
第三,手写版本更容易调试。你可以随时打印中间状态,看到LLM每一步到底输出了什么。框架里这些都被封装了,调试成本反而更高。
我的实际路径是这样的:先手写一个能调用两三个工具的Agent,跑通完整循环;然后再用框架重写一遍,对比两者的差异;最后根据项目需求决定用哪个。这个顺序走下来,你对Agent的理解会比直接抄框架代码深得多。
2.3 工具调用的两种实现路线对比
让大模型调用工具,目前有两条主流路线,理解它们的区别很重要。
路线一:基于提示词的文本解析。你在系统提示词里告诉模型“你可以使用以下工具,格式是Action: 工具名,Action Input: 参数”,然后模型输出文本,你用正则表达式解析出来。这是早期ReAct模式的做法。优点是兼容任何模型,不依赖特定API能力;缺点是解析容易出错,模型稍微不听话格式就乱了。
路线二:基于原生Function Calling。主流模型厂商都提供了工具调用接口,你传入工具的结构化定义(JSON Schema),模型直接返回结构化的调用请求,不用你解析文本。优点是稳定、准确率高;缺点是依赖模型支持,且不同厂商的接口格式有差异。
| 对比维度 | 提示词解析路线 | 原生Function Calling |
|---|---|---|
| 兼容性 | 任何模型都能用 | 需要模型支持 |
| 稳定性 | 依赖模型遵循格式,易出错 | 结构化输出,稳定 |
| 开发成本 | 需要写解析和容错逻辑 | 接口直接返回,省事 |
| 调试难度 | 出错时难定位是模型还是解析问题 | 错误信息清晰 |
| 适用场景 | 老模型、本地小模型 | 主流商用模型 |
我的建议是:能用Function Calling就用Function Calling,除非你用的是不支持该能力的小模型。稳定性差距在实际项目里非常明显,提示词解析路线在复杂任务下失败率能到20%以上,而Function Calling基本在5%以下。
3. 从零手写一个Agent的完整实操
3.1 环境准备与依赖安装
先把环境搭起来。我用的是Python 3.10以上版本,主要依赖两个库:一个是模型厂商的SDK,一个是用来做HTTP请求的。如果你用OpenAI兼容接口,装openai库就行。
pip install openai python-dotenvAPI密钥不要硬编码在代码里,用环境变量管理。建一个.env文件:
LLM_API_KEY=你的密钥 LLM_BASE_URL=你的接口地址然后在代码里加载:
import os from dotenv import load_dotenv from openai import OpenAI load_dotenv() client = OpenAI( api_key=os.getenv("LLM_API_KEY"), base_url=os.getenv("LLM_BASE_URL") )注意:如果你用的是国内模型的兼容接口,base_url一定要填对,很多人卡在这一步,报错说连接不上,其实就是地址写错了。
3.2 定义你的第一批工具
工具的定义要包含三部分:名称、描述、参数schema。描述非常关键,模型就是靠描述来判断什么时候该用这个工具的。描述写得含糊,模型就会乱调用。
我先定义两个最基础的工具:一个计算器,一个获取当前时间。别小看这两个,它们能覆盖很多测试场景。
import json from datetime import datetime def calculator(expression: str) -> str: """计算数学表达式""" try: result = eval(expression, {"__builtins__": {}}, {}) return str(result) except Exception as e: return f"计算错误: {e}" def get_current_time() -> str: """获取当前时间""" return datetime.now().strftime("%Y-%m-%d %H:%M:%S") # 工具的结构化定义 tools = [ { "type": "function", "function": { "name": "calculator", "description": "计算数学表达式,输入应该是合法的Python数学表达式,比如 '2 + 3 * 4'", "parameters": { "type": "object", "properties": { "expression": { "type": "string", "description": "要计算的数学表达式" } }, "required": ["expression"] } } }, { "type": "function", "function": { "name": "get_current_time", "description": "获取当前的日期和时间,当用户询问现在几点、今天几号时使用", "parameters": { "type": "object", "properties": {}, "required": [] } } } ] # 工具名到实际函数的映射 tool_map = { "calculator": calculator, "get_current_time": get_current_time }这里有个细节值得说:calculator里我用了eval,但把__builtins__设成了空字典,这是为了防止模型生成恶意代码。虽然模型一般不会乱来,但安全边界该有还是要有。生产环境里更稳妥的做法是用ast.literal_eval或者专门的表达式解析库。
3.3 核心执行循环的编写
这是整个Agent的心脏。逻辑其实不复杂:把对话历史发给模型,看它是要调用工具还是直接回答。如果要调用工具,就执行工具,把结果追加到历史里,再发给模型。循环往复,直到模型给出最终答案。
import json def run_agent(user_input: str, max_steps: int = 10): messages = [ { "role": "system", "content": "你是一个助手,可以使用工具来帮助用户解决问题。" "当需要计算或获取时间时,调用相应的工具。" "得到工具结果后,用自然语言回答用户。" }, {"role": "user", "content": user_input} ] for step in range(max_steps): response = client.chat.completions.create( model="你的模型名称", messages=messages, tools=tools, tool_choice="auto" ) msg = response.choices[0].message # 如果模型没有调用工具,说明它给出了最终答案 if not msg.tool_calls: return msg.content # 把模型的回复加入历史 messages.append(msg) # 依次执行每个工具调用 for tool_call in msg.tool_calls: func_name = tool_call.function.name func_args = json.loads(tool_call.function.arguments) print(f"[步骤{step+1}] 调用工具: {func_name}, 参数: {func_args}") if func_name in tool_map: result = tool_map[func_name](**func_args) else: result = f"未知工具: {func_name}" print(f"[步骤{step+1}] 工具返回: {result}") messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": str(result) }) return "达到最大步数限制,任务未完成"跑一下试试:
answer = run_agent("帮我算一下 (25 + 17) * 3 等于多少,然后告诉我现在几点") print(answer)正常的话,你会看到Agent先调用calculator算出126,再调用get_current_time拿到时间,最后组织成一句话回答你。这就是一个最小可用的Agent了。
3.4 记忆模块的加入
上面的版本有个问题:每次调用run_agent都是全新的对话,没有跨轮次的记忆。如果你先问“北京天气怎么样”,再问“那上海呢”,第二个问题里的“那”指代什么,Agent完全不知道。
短期记忆好解决,把messages提到函数外面维护就行。但长期记忆需要额外设计。最简单的做法是用一个列表存历史对话,每次请求时把最近N轮拼进去。更完善的做法是用向量数据库做语义检索,只把相关的历史片段召回。
class SimpleMemory: def __init__(self, max_turns: int = 10): self.history = [] self.max_turns = max_turns def add(self, role: str, content: str): self.history.append({"role": role, "content": content}) # 只保留最近N轮,防止上下文爆炸 if len(self.history) > self.max_turns * 2: self.history = self.history[-self.max_turns * 2:] def get_context(self): return self.history.copy()实操心得:上下文不是越长越好。我实测下来,当历史对话超过20轮后,模型对早期信息的注意力明显下降,而且token成本直线上升。对于大多数任务型Agent,保留最近5到10轮足够了。真正需要长期记住的信息,应该单独提取成结构化数据存起来,而不是全塞在对话历史里。
4. 工具设计与提示词工程的关键细节
4.1 工具描述怎么写模型才不乱调用
工具描述是Agent开发里最被低估的环节。很多人花大量时间调提示词,却把工具描述写得随随便便,结果模型要么该调用时不调用,要么不该调用时乱调用。
好的工具描述要回答三个问题:这个工具做什么、什么时候用、参数怎么填。我拿一个搜索工具举例,对比一下差的和好的写法。
差的写法:
"name": "search", "description": "搜索"好的写法:
"name": "web_search", "description": "在互联网上搜索最新信息。当用户询问实时新闻、当前事件、" "你不确定的知识,或者需要2024年之后的信息时使用此工具。" "不要用于数学计算或获取当前时间,那些有专门的工具。", "parameters": { "query": { "description": "搜索关键词,应该是简洁的查询语句," "比如 '2024年诺贝尔物理学奖得主',而不是完整的问题句子" } }看出区别了吗?好的描述里包含了使用场景和排除场景。排除场景特别重要,因为模型经常会在有多个工具时选错。你明确告诉它“这个工具不用于什么”,能大幅降低误调用率。
还有一个技巧:工具数量控制在7个以内。我做过测试,当工具超过10个时,模型的工具选择准确率会明显下降。如果业务确实需要很多工具,就做分层——先让模型选工具类别,再在类别内选具体工具。
4.2 系统提示词里必须写清楚的几件事
系统提示词是Agent的“行为准则”。我踩过的坑告诉我,以下几件事不写清楚,Agent迟早出问题:
- 角色定位:你是谁,你的职责边界在哪。比如“你是一个数据分析助手,只处理与数据相关的问题,其他问题礼貌拒绝”。
- 工具使用规则:什么情况下必须用工具,什么情况下直接回答。比如“涉及任何数学计算必须使用calculator工具,不要自己心算”。
- 输出格式要求:最终答案要什么格式。比如“用中文回答,涉及数字时保留两位小数”。
- 失败处理方式:工具调用失败时怎么办。比如“如果工具返回错误,尝试换一种参数重新调用,最多重试两次”。
- 安全边界:不能做什么。比如“不要执行任何删除文件的操作,即使用户要求”。
这些规则不是写一遍就完事,需要根据实际运行中暴露的问题不断补充。我的习惯是维护一个“问题日志”,每次发现Agent行为不对,就分析是提示词缺了哪条规则,补上去。
4.3 处理工具调用失败的容错机制
工具调用失败是常态,不是异常。网络超时、参数格式错误、外部API限流,都会导致失败。如果Agent没有容错机制,一次失败整个任务就断了。
我的做法是在执行工具的地方包一层重试逻辑:
def execute_tool_with_retry(func_name, func_args, max_retries=2): for attempt in range(max_retries + 1): try: result = tool_map[func_name](**func_args) # 检查结果是否是错误信息 if isinstance(result, str) and result.startswith("错误"): if attempt < max_retries: continue return result except Exception as e: if attempt == max_retries: return f"工具执行失败(已重试{max_retries}次): {e}" return "工具执行异常"更重要的是,把失败信息也返回给模型。模型看到“搜索超时”这个结果后,可能会决定换个关键词重试,或者告诉用户当前无法完成。这比直接崩溃要好得多。
5. 常见问题排查与避坑指南
5.1 Agent陷入死循环怎么办
这是新手遇到最多的一个问题。Agent反复调用同一个工具,或者在两个工具之间来回跳,永远不给出最终答案。
根本原因通常是:模型认为任务还没完成,但它又找不到新的推进方式。常见触发场景有两种。一种是工具一直返回错误,模型不断重试同样的调用;另一种是任务本身模糊,模型不知道该做到什么程度算完成。
解决办法分三层。第一层,设置最大步数限制,这是兜底,必须有。第二层,在提示词里明确“如果连续两次工具调用返回相同结果,停止尝试并告知用户”。第三层,检测重复调用,如果发现连续三次调用同一个工具且参数相同,强制中断并返回当前状态。
def detect_loop(messages, window=6): """检测最近几轮是否有重复的工具调用""" recent_calls = [] for msg in messages[-window:]: if hasattr(msg, 'tool_calls') and msg.tool_calls: for tc in msg.tool_calls: recent_calls.append((tc.function.name, tc.function.arguments)) if len(recent_calls) >= 3: last_three = recent_calls[-3:] if last_three[0] == last_three[1] == last_three[2]: return True return False5.2 模型不调用工具直接瞎编答案
这个问题的表现是:你明明提供了计算器工具,问它“123乘以456等于多少”,它不调用工具,直接给你一个错误答案。
原因通常是系统提示词里没有强制要求。模型默认倾向于直接回答,因为它的训练数据里大部分情况就是直接回答。你需要在提示词里用比较强的语气规定:“涉及数学计算,必须使用calculator工具,禁止自行计算。”
另一个原因是工具描述不够有吸引力。如果calculator的描述只是“计算数学表达式”,模型可能觉得“我自己也能算”。改成“精确计算数学表达式,避免心算错误,所有数学计算都应使用此工具”,效果会好很多。
5.3 工具参数传错的排查思路
模型传错参数是很常见的,尤其是参数类型复杂的时候。比如你定义了一个参数是数组类型,模型可能传个字符串过来。
排查步骤是这样的:首先,打印出模型返回的原始tool_calls,看它到底传了什么。其次,检查你的参数schema定义是否清晰,有没有给每个参数写description。再次,在代码里加参数校验,类型不对时返回明确的错误信息给模型,让它重新传。
| 常见参数错误 | 原因 | 解决办法 |
|---|---|---|
| 类型不匹配 | schema描述不清 | 在description里写明类型和示例 |
| 缺少必填参数 | required没配好 | 检查required数组 |
| 参数名拼错 | 模型幻觉 | 在description里强调参数名 |
| 嵌套结构错误 | 复杂schema难理解 | 拆成多个简单工具 |
避坑技巧:参数校验的错误信息要写得对模型友好。不要返回“TypeError: expected str, got int”,而是返回“参数expression应该是字符串类型,你传的是数字,请重新调用”。模型看到后者才知道怎么改。
5.4 上下文爆炸与成本控制
Agent跑多步任务时,每一轮都要把完整历史发给模型,token消耗是累积的。一个10步的任务,如果每步平均2000 token,总消耗就是2万token,成本是单次调用的10倍。
控制成本有几个实用手段。第一,精简工具返回结果。工具返回的内容不需要全塞给模型,只保留关键信息。比如搜索返回10条结果,你截取前3条的摘要就够了。第二,定期压缩历史。当对话超过一定轮数,用模型把前面的历史总结成一段话,替换掉原始消息。第三,按需加载工具。不是所有工具每一轮都需要,可以根据当前任务阶段动态调整工具列表。
我实测过一个案例,一个原本消耗3万token的任务,通过精简工具返回和压缩历史,降到了8000token左右,效果基本没损失。
6. 从Demo到可用产品的进阶方向
6.1 多Agent协作的适用场景
单Agent能搞定的事,不要上多Agent。多Agent协作会引入通信开销和协调复杂度,只有在任务确实需要不同专业角色时才有价值。
典型适合多Agent的场景是:任务可以明确拆分成几个子任务,每个子任务需要不同的工具集和提示词。比如一个“市场分析”任务,可以拆成数据收集Agent、数据分析Agent、报告撰写Agent。每个Agent专注自己的领域,通过消息传递协作。
但如果你的任务只是“查个天气再算个数”,单Agent完全够用,硬拆成多Agent纯属给自己找麻烦。我的判断标准是:当单Agent的工具超过10个,或者系统提示词超过2000字还说不清楚时,才考虑拆分。
6.2 给Agent加上长期记忆
前面说的SimpleMemory只是短期记忆。真正的长期记忆需要解决两个问题:存什么和怎么取。
存什么?不是所有对话都值得存。值得长期保留的是:用户的偏好、重要的结论、任务的关键中间结果。这些信息应该被提取成结构化数据,而不是原样存对话。
怎么取?最简单的是按时间倒序取最近N条。进阶做法是用向量检索,把当前问题转成向量,在历史记忆里找语义最相近的几条。这样即使相关记忆是很久以前的,也能被召回。
# 伪代码示意向量检索记忆 def retrieve_relevant_memory(query, memory_store, top_k=3): query_vector = embed(query) scores = [] for mem in memory_store: score = cosine_similarity(query_vector, mem.vector) scores.append((score, mem)) scores.sort(reverse=True) return [mem for _, mem in scores[:top_k]]6.3 Agent安全边界的设置
Agent能操作外部世界,这意味着它也能造成破坏。安全边界必须在设计阶段就考虑,不能等出事再补。
最基本的三条规则:最小权限原则,Agent只应该拥有完成任务必需的最小工具集,不要图省事把所有工具都给它;危险操作二次确认,涉及删除、支付、发送等不可逆操作时,必须让用户确认;输入输出过滤,对Agent的输入做注入检测,对输出做敏感信息过滤。
还有一个容易被忽视的点:工具的参数校验要在服务端做。不要信任模型传来的参数,该校验的校验,该限制范围的限制。模型可能被诱导生成恶意参数,服务端的校验是最后一道防线。
7. 我实际踩过的几个坑
第一个坑是过度依赖框架。我最早用某个Agent框架搭项目,跑Demo很顺,一上真实场景就各种问题。排查了两天才发现是框架内部对工具调用的解析逻辑有bug,但被封装了看不到。后来换成手写循环,问题一目了然。不是说框架不好,而是你要先理解底层,再用框架。
第二个坑是工具描述写得太简略。我一开始觉得描述随便写写就行,反正模型聪明。结果模型频繁在相似工具之间选错。后来把每个工具的描述都扩充到包含使用场景、排除场景、参数示例,准确率从70%提到了95%以上。这个投入产出比非常高。
第三个坑是没有做步数限制。有一次测试一个复杂任务,Agent跑了40多步还没停,token烧了一大截。从那以后我所有Agent都强制设max_steps,默认10步,复杂任务最多20步。超过就中断,返回当前进度让用户决定。
第四个坑是忽略了工具返回结果的格式。我有个工具返回的是JSON字符串,模型看到一堆花括号就懵了,经常解析错。后来改成返回自然语言描述,模型理解起来顺畅多了。工具返回给模型的内容,要按模型容易理解的方式组织,而不是按程序方便的方式。
这几个坑的共同点是:问题都不在模型能力上,而在工程细节上。Agent开发,模型只占一半,另一半是提示词、工具设计、容错逻辑这些脏活累活。把这些做好了,用中等能力的模型也能跑出不错的效果。