1. 为什么“会调用API”和“懂Agent原理”之间隔着一道鸿沟
很多人第一次接触AI Agent,都是从调用某个大模型API开始的。写几行Python,把用户输入拼进prompt,拿到返回结果,再根据结果决定下一步做什么——这确实就是一个最朴素的Agent雏形。但问题在于,当你用LangChain或者某个现成框架搭出一个能跑的Demo之后,你大概率会产生一种错觉:我已经会做Agent了。
这种错觉在面试或者实际项目里会迅速被击碎。面试官问一句“你的Agent怎么做工具选择的?”,你可能回答“框架自动处理的”。再问“如果模型返回了不存在的工具名怎么办?”,你就卡住了。再追问“多轮对话里上下文怎么截断、怎么保留关键信息?”,你只能说“用框架的memory模块”。这就是“会调用”和“懂原理”之间的真实差距——你用的是别人封装好的抽象层,而抽象层下面的东西你一无所知。
周瑜的这套“零基础手写AI Agent”路径,核心价值就在于把抽象层一层层剥开,让你用最原始的方式重新实现一遍。不是让你抛弃框架,而是让你在用过框架之后,能清楚地知道框架帮你做了什么、为什么这么做、如果不这么做会出什么问题。这就像学编程不能只会用IDE的自动补全,还得知道编译器在背后干了什么。
这篇文章会沿着“从会调用到懂原理”这条主线,把AI Agent的核心机制拆成几个可以动手实现的模块。每个模块我都会先讲清楚“为什么需要它”,再给出“最小可运行的手写实现”,最后补充“实际项目里容易踩的坑”。适合已经用过至少一种大模型API、想真正搞明白Agent内部运转逻辑的开发者。零基础也能跟,但你需要至少能看懂Python代码。
2. Agent的本质:一个带工具调用能力的循环控制器
2.1 剥掉框架外衣后,Agent只剩三件事
不管你用的是什么框架,一个AI Agent在运行时本质上只做三件事:接收输入、决定行动、执行行动。这个循环会一直持续,直到Agent认为任务完成或者达到某个终止条件。
“决定行动”这一步是整个Agent的灵魂。在纯文本对话模型里,模型只能输出文字;但在Agent场景下,我们需要模型输出一个结构化的“行动指令”,比如“调用天气查询工具,参数是城市=北京”。这个结构化输出的过程,就是所谓的Function Calling或者Tool Use。
手写Agent的第一步,就是放弃框架提供的AgentExecutor,自己写一个循环。下面是一个最简版本:
import json def simple_agent_loop(user_input, tools, model_client, max_turns=10): messages = [{"role": "user", "content": user_input}] for turn in range(max_turns): response = model_client.chat(messages, tools=tools) if response.type == "tool_call": tool_name = response.tool_name tool_args = json.loads(response.tool_args) result = tools[tool_name](**tool_args) messages.append({"role": "assistant", "content": response.raw}) messages.append({"role": "tool", "content": str(result)}) else: return response.content return "达到最大轮次限制"这段代码不到20行,但它已经包含了Agent的核心骨架。框架做的事情无非是在这个骨架上加了错误重试、并行工具调用、流式输出、记忆管理等功能。你先把这个循环跑通,后面加什么都是在这个基础上做增量。
2.2 工具描述的质量直接决定Agent的智商
很多人手写Agent时最容易忽略的一点是:工具的描述文本比工具本身的实现更重要。模型是根据你提供的工具描述来决定调用哪个工具的。如果你的描述写得含糊不清,模型就会选错工具或者传错参数。
举个例子,你有一个查询天气的工具和一个查询空气质量指数的工具。如果你把天气工具描述成“获取环境信息”,把空气质量工具也描述成“获取环境信息”,模型大概率会随机选一个。正确的做法是让描述具有排他性:
tools = [ { "name": "get_weather", "description": "查询指定城市的当前天气状况,包括温度、湿度、风力。不包含空气质量数据。", "parameters": { "city": {"type": "string", "description": "城市名称,如'北京'"} } }, { "name": "get_aqi", "description": "查询指定城市的空气质量指数和主要污染物。不包含温度湿度等气象数据。", "parameters": { "city": {"type": "string", "description": "城市名称,如'北京'"} } } ]我在实际项目里做过对比测试:同一套工具,描述写得粗糙时模型选错工具的概率大约在15%到20%;把描述改写成上面这种“包含什么、不包含什么”的格式后,选错率降到了3%以下。这个改进成本极低,但效果立竿见影。
2.3 手写循环时必须处理的三个边界情况
自己写Agent循环,有三类边界情况是框架帮你处理了但你不知道的:
第一,模型返回了不存在的工具名。有些模型在压力下会“幻觉”出一个工具名。你的代码必须捕获KeyError并给模型返回一个错误信息,让它重新选择。直接崩溃是最差的做法。
第二,工具执行超时或抛异常。工具背后可能是一个HTTP请求,可能超时。你需要给工具执行加上超时控制,并把异常信息作为工具结果返回给模型,让模型决定是重试还是换一个方案。
第三,模型连续多轮调用同一个工具且参数相同。这说明模型陷入了死循环。你需要在循环里加一个简单的去重检测:如果连续两轮的工具调用完全一致,就强制中断并返回提示。
这三个边界情况处理好了,你的手写Agent在稳定性上就不会比框架差太多。
3. 从零实现工具调用:不用框架怎么让模型“动手”
3.1 Function Calling的底层其实就是提示词工程
很多人以为Function Calling是模型的原生能力,实际上它的底层机制是:你在系统提示词里注入了一段关于可用工具的结构化描述,模型在训练时见过大量“根据工具描述生成调用参数”的样本,所以它能输出符合格式的JSON。
这意味着两件事:第一,你可以不用任何框架,纯靠提示词实现工具调用;第二,如果模型比较弱,你可以通过优化提示词来提升调用准确率。
下面是一个不依赖任何SDK的纯提示词版本:
SYSTEM_PROMPT = """你是一个可以调用工具的助手。可用工具如下: 工具名:search_web 描述:在互联网上搜索信息 参数:{"query": "搜索关键词"} 工具名:calculate 描述:执行数学计算 参数:{"expression": "数学表达式,如'2+3*4'"} 当你需要调用工具时,只输出一行JSON,格式为: {"tool": "工具名", "args": {参数对象}} 当你不需要调用工具时,直接输出回答文本。"""然后你在解析模型输出时,先尝试用json.loads解析,如果成功且包含tool字段,就执行工具调用;否则当作普通文本返回。这个方案在GPT-3.5级别的模型上就能跑通,准确率取决于你的提示词质量和模型能力。
3.2 参数校验:模型给的参数不一定能用
模型输出的工具参数经常会有小问题:数字被包在字符串里、布尔值写成了"true"而不是true、必填参数缺失、参数名拼写错误。如果你直接把json.loads的结果传给工具函数,轻则报错,重则产生错误的行为。
我建议在工具执行前加一层参数校验和清洗:
def validate_and_clean(tool_schema, raw_args): cleaned = {} for param_name, param_spec in tool_schema["parameters"].items(): if param_name not in raw_args: if param_spec.get("required", False): raise ValueError(f"缺少必填参数: {param_name}") continue value = raw_args[param_name] expected_type = param_spec["type"] if expected_type == "integer" and isinstance(value, str): value = int(value) elif expected_type == "number" and isinstance(value, str): value = float(value) elif expected_type == "boolean" and isinstance(value, str): value = value.lower() == "true" cleaned[param_name] = value return cleaned这段代码看起来不起眼,但它能挡掉实际项目中大约一半的工具调用失败。尤其是当你的Agent面向普通用户时,模型面对各种奇怪的输入,参数格式出错的概率会明显上升。
3.3 多工具并行调用:手写版本怎么做
当用户问“北京和上海今天天气怎么样”时,理想的Agent应该同时调用两次天气查询工具,而不是串行调用。框架通常支持并行工具调用,手写版本也可以做到。
核心思路是:模型在一轮回复中可能返回多个工具调用请求。你需要把模型输出解析成一个列表,然后并发执行所有工具,最后把所有结果一起返回给模型。
import concurrent.futures def execute_tools_parallel(tool_calls, tools): results = [] with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: future_map = {} for call in tool_calls: future = executor.submit(tools[call["tool"]], **call["args"]) future_map[future] = call for future in concurrent.futures.as_completed(future_map): call = future_map[future] try: result = future.result(timeout=30) except Exception as e: result = f"工具执行失败: {str(e)}" results.append({"tool": call["tool"], "result": result}) return results并行调用能把多工具场景的响应时间从“各工具耗时之和”降到“最慢那个工具的耗时”。在工具涉及网络请求时,这个优化效果非常明显。
4. 记忆管理:让Agent记住上下文而不是简单截断
4.1 朴素截断为什么会让Agent变傻
最简单的记忆管理就是保留最近N轮对话,超出部分直接丢掉。这个方案在简单场景下能用,但在Agent场景下会出大问题。
原因在于Agent的对话历史里包含了大量的工具调用记录。一轮完整的工具调用可能产生三四条消息(用户输入、模型决定调用工具、工具返回结果、模型总结)。如果你按消息条数截断,很可能把“模型决定调用工具”这条消息保留了,但把“工具返回结果”截掉了。模型看到自己说要调用工具但没有结果,就会陷入困惑。
更合理的做法是按轮次截断:把一次完整的“用户输入到模型最终回复”视为一轮,保留最近K轮。这样能保证工具调用链的完整性。
4.2 用摘要压缩历史信息
按轮次截断的问题是:如果一轮对话里包含了很长的工具返回结果(比如搜索了十篇文章),这一轮就会占用大量token。这时候就需要对历史轮次做摘要压缩。
我的做法是:保留最近2轮完整对话,更早的轮次用模型生成一段摘要替换。摘要的提示词大概是这样的:
SUMMARY_PROMPT = """请将以下对话历史压缩成一段简洁的摘要,保留关键事实、用户偏好和已完成的行动。 不要遗漏任何用户明确提出的要求。 对话历史: {history} 摘要:"""摘要长度控制在200字以内。这样即使对话进行了20轮,历史部分的token占用也能控制在合理范围内。
4.3 关键信息提取:比摘要更精准的方案
摘要方案有个缺点:它是有损压缩,可能丢掉一些细节。对于需要精确记忆的场景(比如用户说“我上次说的那个订单号是12345”),摘要可能会把订单号弄丢。
更稳妥的方案是维护一个结构化的“关键信息槽位”。在每轮对话结束后,用模型提取出值得记住的信息,存到一个字典里:
EXTRACT_PROMPT = """从以下对话中提取需要长期记住的关键信息。 以JSON格式输出,key是信息类别,value是具体内容。 如果没有新的关键信息,输出空对象。 对话: {conversation} 关键信息:"""然后在构建模型输入时,把关键信息字典序列化后放在系统提示词里。这样既节省token,又不会丢失重要细节。
5. 错误处理与重试:Agent稳定性的真正分水岭
5.1 模型输出格式错误的分类处理
手写Agent时,模型输出格式错误是最常见的失败原因。错误可以分成几类,每类需要不同的处理策略:
| 错误类型 | 典型表现 | 处理策略 |
|---|---|---|
| JSON解析失败 | 输出包含多余文字或格式不对 | 用正则提取JSON部分重试 |
| 工具名不存在 | 调用了未定义的工具 | 返回错误信息让模型重选 |
| 参数缺失 | 必填参数没给 | 返回缺失字段让模型补充 |
| 参数类型错误 | 字符串传给了数字参数 | 自动类型转换后重试 |
| 工具执行异常 | 网络超时、API报错 | 返回异常信息让模型决策 |
关键原则是:任何错误都不要直接抛给用户,而是作为工具结果返回给模型,让模型有机会自我修正。这就像给Agent加了一层“容错缓冲”,大部分格式错误模型在第二轮就能自己改对。
5.2 重试次数和退避策略
重试不能无限进行。我的经验值是:单个工具调用最多重试2次,整个Agent循环最多15轮。超过限制就返回一个友好的错误提示。
对于工具执行失败的重试,建议加一个简单的退避:第一次失败后等1秒重试,第二次失败后等3秒。如果是网络类工具,这个退避能显著提升成功率。
import time def retry_tool_call(tool_func, args, max_retries=2): for attempt in range(max_retries + 1): try: return tool_func(**args) except Exception as e: if attempt == max_retries: return f"工具执行失败(已重试{max_retries}次): {str(e)}" time.sleep(2 ** attempt)5.3 日志记录:排查Agent问题的唯一依靠
Agent出问题时,你面对的是一个多轮对话加多次工具调用的复杂链路。没有详细的日志,你根本不知道是哪一步出了问题。
我建议在Agent循环的每个关键节点都打日志:模型输入、模型原始输出、解析后的工具调用、工具执行结果、最终回复。日志用结构化格式(JSON Lines),方便后续检索和分析。
import logging import json logger = logging.getLogger("agent") def log_step(step_type, data): logger.info(json.dumps({"step": step_type, "data": data}, ensure_ascii=False))在实际排查中,我经常发现问题的根源是某次工具返回了超长的结果,把上下文撑爆了,导致模型后续输出质量下降。这种问题没有日志根本发现不了。
6. 从手写Demo到可用Agent还差哪些工程化改造
6.1 流式输出:用户体验的关键提升
手写Agent跑通之后,第一个要加的工程化能力就是流式输出。用户不希望等10秒才看到第一个字。流式输出需要你在模型客户端层面支持SSE(Server-Sent Events),然后在Agent循环里把模型的文本增量实时推送给前端。
难点在于:当模型决定调用工具时,流式输出会先输出一段工具调用的JSON片段。你需要在前端做判断:如果是工具调用,就显示“正在查询...”的提示;如果是普通文本,就逐字显示。
6.2 超时控制和并发限制
生产环境的Agent必须设置多层超时:单次模型调用超时(建议30秒)、单次工具执行超时(建议15秒)、整个Agent循环超时(建议60秒)。任何一层超时都要有优雅的降级处理。
并发限制同样重要。如果你的Agent服务同时处理多个用户请求,每个请求都可能触发多次模型调用和工具调用。不加限制的话,很容易把下游API打挂。建议用信号量或令牌桶做限流。
6.3 可观测性:知道Agent在干什么
除了日志,你还需要指标监控:每轮对话的平均轮次、工具调用成功率、模型调用延迟分布、token消耗量。这些指标能帮你发现性能瓶颈和异常模式。
我习惯在Agent循环里埋几个计数器,每次请求结束后上报。比如“本轮对话用了5轮,调用了3次工具,消耗了2000个token”。积累一段时间后,你就能看出哪些类型的用户输入会导致Agent“绕远路”,然后针对性优化提示词。
7. 手写Agent之后再看框架:哪些该用,哪些该自己写
7.1 框架帮你省掉的其实是脏活累活
手写一遍之后你会发现,框架的核心价值不在于“实现了Agent循环”这个逻辑本身,而在于帮你处理了大量边界情况和工程细节:重试、超时、日志、流式、并发、状态管理。这些才是真正耗时的地方。
所以我的建议是:理解原理用手写,生产落地用框架。你先手写一遍,知道每个环节可能出什么问题;然后在实际项目里用框架,但遇到问题时你知道该去框架的哪个模块找原因。
7.2 什么情况下应该坚持手写
有两种情况我建议坚持手写而不是用框架:
第一种是对延迟极度敏感的场景。框架的抽象层会带来额外的开销,手写版本可以做到更精简的调用链。如果你的Agent需要在200毫秒内响应,手写是更好的选择。
第二种是需要深度定制的场景。比如你需要实现一种特殊的记忆压缩算法,或者需要在工具调用前后插入自定义的权限校验逻辑。框架的扩展点可能不够灵活,手写反而更省事。
7.3 从手写版本迁移到框架的时机
当你发现手写版本的代码里,错误处理、日志、重试这些非核心逻辑的代码量已经超过了核心逻辑本身,就是时候考虑迁移到框架了。框架把这些东西标准化了,你只需要关注业务逻辑。
但迁移不是重写。你可以把手写版本里的工具定义、提示词模板、参数校验逻辑直接搬到框架里,只把循环控制和错误处理交给框架。这样迁移成本最低,也不会丢失你在手写过程中积累的经验。
8. 我踩过的几个坑和对应的解法
第一个坑是工具描述里的参数名和实际函数参数名不一致。模型按照描述里的参数名生成调用,但你的函数签名用的是另一个名字,结果就是TypeError。解法很简单:工具描述里的参数名必须和函数参数名完全一致,最好用自动化测试来校验。
第二个坑是模型在长对话中逐渐“忘记”工具的存在。对话轮次多了之后,系统提示词里的工具描述被淹没在历史消息里,模型开始直接用文本回答而不是调用工具。解法是把工具描述放在每轮消息的最前面,或者用模型支持的“工具”字段而不是纯提示词。
第三个坑是工具返回结果太长导致上下文爆炸。有一次我接了一个搜索工具,返回了整页HTML,直接把上下文撑到了模型上限。解法是在工具层面做结果截断和摘要,只返回最相关的部分。
第四个坑是并发调用时工具之间的状态冲突。两个工具调用同时修改同一个全局变量,导致结果不可预测。解法是工具函数尽量设计成无状态的,必须共享状态时加锁。
这些坑在框架里可能已经被处理了,但如果你不知道它们存在,遇到问题时就会毫无头绪。手写一遍的价值就在于把这些坑都踩一遍,以后用框架时心里有底。
9. 进阶方向:从单Agent到多Agent协作
手写单Agent跑通之后,下一步自然是多Agent协作。但我要提醒一句:多Agent的复杂度不是线性增长,而是指数增长。两个Agent之间的通信协议、任务分配、结果合并,每一个环节都可能出问题。
我的建议是先把单Agent做到足够稳定,再考虑多Agent。如果确实需要多Agent,从最简单的“主管-执行者”模式开始:一个主管Agent负责拆解任务,多个执行者Agent负责执行具体子任务。主管Agent根据执行者返回的结果决定下一步。
手写多Agent的关键是设计好Agent之间的消息格式。我通常用一个统一的JSON结构:
{ "from": "agent_name", "to": "agent_name", "type": "task|result|query", "content": "...", "metadata": {} }这个格式简单但够用。所有Agent都按照这个格式收发消息,通信层就不容易出乱子。
最后分享一个我在实际项目里验证过的经验:Agent的能力上限不取决于模型有多强,而取决于工具设计得有多好。一个中等能力的模型配上精心设计的工具,表现往往超过一个强模型配上粗糙的工具。所以与其花时间调模型参数,不如多花时间打磨工具的描述、参数和返回格式。这个投入产出比是最高的。