深入理解Agent:从0实现function call
朋友,你有没有过这种经历?
装了LangChain,跑通官方demo,觉得Agent这东西,哇,真神奇。可当你想加点自己的工具,改个参数,或者debug时撞上那堆不知道从哪冒出来的报错——瞬间就懵了。我那天就是这样:对着IDE里那个黑框框,差点想把电脑砸了。
凭什么我不懂它里面在转什么?
不就是个函数调用吗?我不信邪。关掉LangChain,扔掉所有花里花哨的框架,就带一个requests库和OpenAI的SDK,从零开始手撕一个能调用工具的Agent。目标小到过分:能回答天气问题就行。
结果你猜怎么着?等我自己写完了,那种感觉——
从“这家伙怎么用”变成了“哈哈,老子会造了”。
先搭个小剧场:问天气
我定义了两个纯朴到可爱的函数:
- `get_weather(location)` —— 不管你去哪,它都告诉你“天气晴朗”。为什么用假数据?因为我想先跑通逻辑,后面换真API跟玩儿似的。
- `send_messages(messages)` —— 拿去问deepseek-chat模型。便宜又快,爱了爱了。
这里插一句,如果你也想自己写,第一步就把LLM调用的壳封装好,记得留出tools参数的位置。后面你就知道这个“提前布局”有多香了。
设计思路:说白了就是一个循环,你别想复杂了
用户问“天津天气怎么样?”Agent内部跑了两轮LLM调用:
1. 第一轮:模型一看是问天气,“哦,该调用get_weather”,返回一个tool call,参数是“天津”。
2. 我(本地代码)执行这个函数,拿到结果“天气晴朗”。
3. 第二轮:我把函数结果塞回消息列表,再问模型一次。这次模型看到已经有天气信息了,直接组织语言回复用户。
听起来简单到像个笑话?但第一个坑就把我绊了个嘴啃泥——我没把tool_call_id对上号!
模型给的每个tool call都有一个唯一ID,你把执行结果追加到消息里时,role="tool"的那条消息必须带上tool_call_id。我一开始没注意,结果模型看完结果一脸懵,继续说要调用函数——死循环了,像狗狗追自己尾巴一样停不下来。
后来翻了OpenAI官方文档才明白:工具调用的整个流程,模型只负责给建议(工具名+参数),真正动手执行的是你(代码)。 然后你通过role="tool"的信使把结果喂回去,信使身上必须贴着模型给的ID标签。这一步错了,Agent就傻了。
从零实现:我的架构就三层,多一层都不加
第一层:工具层
一个字典存所有函数引用,一个列表存每个工具的JSON Schema(名字、描述、参数你都得写明白)。这块坑也深:Schema的description写得好不好,直接决定模型打不打得中靶心。
比如get_weather的description我改了四五遍,最后写成:
“根据用户问题中的地点获取当地当前天气,返回字符串”
比最初那个敷衍的“获取天气”触发率高出一大截。你想想,模型也是看说明书的,说明书写得模糊,它当然瞎猜。
第二层:调度层
核心循环函数run_agent(user_input),就干这几件事:
- 初始消息插入用户输入,带上`tools`参数。
- 调LLM,看响应里有没有`tool_calls`。
- 如果有,挨个执行,结果追加到消息(记得带上`tool_call_id`——重要的事说三遍,这ID不能错)。
- 再次请求LLM,直到响应里没有`tool_calls`或者达到最大轮数(我设了5轮,保命用的)。
- 如果函数执行炸了,我做**快照回滚**:记录当前消息列表长度,出错了就截断恢复,然后告诉模型“工具调用失败,你换个姿势”。
第三层:解析层
我写了三道防线:
比如工具名在字典里找不到、参数JSON解析失败、或者函数执行时抛异常——任何一个出问题,我都不往消息列表里追回错误结果(那会让模型更困惑),而是直接返回一条系统消息“工具调用异常,请重试”,并回滚状态。
这三道防线全是拿血泪换来的。第一次实现时没做异常捕获,工具名拼错了,模型返回get_weatherr,我直接跑去globals()里找,一个KeyError把整个循环干崩了。后来加上try-except,再把错误信息包装成自然语言塞给模型,你猜怎么着?它居然会自己道歉!
“抱歉,刚刚工具名写错了,重新调用get_weather。”
有意思吧?你教它用工具,它竟然学会认错了。
踩坑记录:我踩过的,你别再踩了
1️⃣ 工具返回的内容必须是字符串
一开始我图省事,直接返回了个dict(比如{"weather":"sunny"}),结果模型报错“无法处理对象”。因为消息的content字段就得是字符串。后来所有工具返回值我都先str()或者拼成文本。
2️⃣ 忘记设置MAX_TOOL_ROUNDS
有一次测试,函数返回的结果恰好让模型再次调用同一个函数,加上我没做上限——它调了二十多次还不肯停!我赶紧加了计数器,超过5轮强制退出,并返回“今日工作结束,请简化问题”。
你想想,这要是线上,API费用蹭蹭往上涨,老板看了心碎。
3️⃣ 并行调用时注意依赖
后来我加了第二个工具get_uv_index(location),测试“天津的天气和紫外线指数怎么样?”模型一次返回两个tool calls。我一开始顺序执行,没问题。但如果工具之间有依赖(比如先查A再查B),就不能并行。我特意留了个开关,让你自己决定要不要并发。
4️⃣ 缓存要谨慎,别乱用
为了省API调用,我试过给工具结果加缓存(functools.lru_cache)。然后发现:如果缓存一直有效,用户第二次问天气时,模型拿到的还是上次的假数据!静态演示可以,真实场景就是灾难。所以缓存必须有TTL,或者只在会话级缓存,跨会话别复用。
到底要不要上框架?我的答案很直白
做完这个自己动手的Agent,我回头再看LangChain的AgentExecutor,才明白它帮我封装了什么:循环逻辑、错误重试、历史管理、并行执行……
如果你只是快速验证想法,用框架没毛病,省心。
但如果你想在项目里定制一些行为(比如自定义错误处理、特殊的中断逻辑),或者排查线上问题,不懂底层原理,那个感觉就像闭着眼睛开车。
我的建议是:
- **如果你是新手,花一个周末从零实现一遍function call。** 只需几十行核心代码,但你会彻底搞懂它怎么转起来的。那个感觉,比你用十个框架都值。
- **如果是生产项目,用框架+自己理解原理,两边都别丢。** 比如现在我项目里核心流程还是用LangChain的Agent,但修改工具描述、调试超时、加日志审计我都自己写——因为我面向的是哪个接口,我心里门儿清。
生产环境Checklist(我踩完坑给你列的)
把demo推上线前,至少确认这几点:
- 所有工具函数返回`str`,异常要完整捕获。
- 设置最大工具轮数,3–5轮差不多了。
- 有快照回滚机制,防止中间状态污染下一步。
- 每个工具调用都打日志(哪个工具、参数、执行耗时)。
- `tool_call_id`在返回结果时严格对应,一个萝卜一个坑。
- 工具执行设置超时(我用`concurrent.futures`包的,很简单)。
- 请求LLM加重试机制,模型API偶尔会挂,你懂的。
- 消息列表按token长度裁剪,别简单按条数删,会删过头——我碰到过裁掉上一条函数调用结果导致模型重复产生tool_call的蠢事。
最后说点真感想
从零实现function call最让我意外的,是那个反直觉的真相:
模型其实不调用函数,它只是提建议。真正执行的是你的代码。
这个认知,直接改变了我对Agent的敬畏感——它并不是什么神奇地操控外部系统的黑魔法,而是我们按照它的“建议”去操作,再把结果反馈给它。理解了这一点,你就掌握了主控权。你可以选择不执行危险调用,可以给结果加预处理、校验,甚至伪造结果(当然,别乱搞)。
如果你也在学Agent,别再只装框架了。花一个下午,用50行Python,
去感受那种 “我,让AI,听我指挥”的掌控感。
代码我丢在GitHub了:github.com/astordu/agent-from-zero
里面有三个版本:最简演示、支持并行调用、生产加固版。 拿去玩,有问题欢迎提issue。
本文基于我自己真实试验记录,测试环境:Python 3.11,openai 1.30+,deepseek-chat模型(2025年3月版本)。
读者评论 4