
学习进入第三天画风明显不一样了。前两天的内容还可以说是在“熟悉手感”——跑通接口、看看模型返回、研究提示词怎么写但到了AI应用开发真正的核心环节问题开始变得棘手模型很会聊却不会“干活”。这篇 day03 的学习笔记我打算聚焦三件事让模型拥有对话记忆、让模型学会调用外部工具、以及把这些能力串成一个最简的 Agent 雏形。如果你正在跟着一条AI应用开发学习路线往下走这篇内容几乎是你从“会调 API”切换到“会做应用”的分水岭。1. 从Day02到Day03为什么好端端的Demo一到“连招”就哑火1.1 前两天到底学了什么day01 一般是从请求一个模型接口开始的。拿到 API Key构造一个最简单的 prompt然后看模型返回一段文本。这个过程本质上是理解“大模型是什么、怎么通信”。到 day02多数学习者会开始研究输出解析、温度参数、角色设定甚至尝试把流式输出接入一个聊天框。这些都很重要但它们仍然停留在“一问一答”的玩具层面。之所以说 day02 结束后的项目还是玩具是因为每次请求都是独立的。你问模型“我喜欢喝美式咖啡”下一轮再问“那我应该点什么”模型大概率不会记得你刚才说过什么。很多人在这一步开始怀疑模型智力有问题其实是应用层没有把上下文传进去。对话的“记忆”并不会天然存在服务器上它需要客户端自己维护并反复提交。1.2 哑火的本质无状态、无工具、无循环真正让 Demo 哑火的通常不是模型本身笨而是三个能力缺失。第一是无状态没有消息历史模型每次都是“第一次见到你”。第二是无工具模型只能靠训练时学到的知识回答无法查询实时信息比如天气、库存、运费甚至你家楼下的核酸点。第三是无循环模型回答完一句话就结束了它不会根据你的需求去主动发起第二次调用形成“思考-行动-观察”的闭环。这三个缺失恰好就是 day03 要补的课。你可以把大模型想象成一个智商很高但没手没脚的知识分子它知道“寄快递大概要称重、算距离”但它不知道你的快递系统里具体的计费规则。如果不把“手和脚”通过代码递给它它的所有回答都只能停留在“泛泛而谈”。而让模型调用工具的机制行业里通常叫 Function Calling或叫 Tool Use各平台叫法略有差异核心原理一致。1.3 第三天的主线任务所以我把 day03 的目标拆成三个可验证的阶段。第一阶段做一个能记住上下文的聊天接口至少要能连续对话 5 轮不“失忆”。第二阶段给模型注册至少两个工具比如查天气、算快递运费让模型能根据用户的问题自动选择工具并返回可解析的结构化结果。第三阶段把前两个能力合并写一个循环逻辑让模型可以多次调用工具直到完成用户需求这就是 Agent 的雏形。很多教程会把 Agent 讲得特别高大上但如果你连 Function Calling 都没跑通Agent 就是空中楼阁。所以这篇文章的核心关注点非常基础先把机制弄明白再谈架构。下面每一章我都会给出可以直接跑的代码片段但更重要的是解释背后的“为什么”。2. 给对话装“记忆”会话状态与上下文管理的工程化设计2.1 消息序列就是记忆本身先搞懂角色协议所有主流大模型 API 的记忆机制都是同一个套路客户端把一段messages数组发给服务端数组里每一个元素都有角色标签。常见的角色有system系统指令、user用户输入、assistant模型回复、tool工具返回结果。模型看到哪一段内容完全取决于你给它哪一段内容。它自己不会偷偷保存任何东西。这意味着两件事。第一你要在应用层维护一个消息列表每次请求都把历史追加上去第二system消息可以常驻在最前面用来声明“你是谁、你该遵循什么规则”。打个比方messages数组就是递给模型的“工作文件夹”你把哪几页文件递进去它就基于哪几页作答。你没有递给它的内容它一概不知。这里有一个容易搞错的地方user和assistant消息必须交替出现尤其是在多轮对话里。如果连续出现两条user消息有些平台会直接忽略前一条有些则会把它们合并导致上下文出现隐性丢数据。我在本机练习时踩过一次前端把用户按回车前的“正在输入”状态也算成一条消息写进了数组结果模型看起来“忘了”用户刚发的上一条问题实际上是因为数组里有两条连续的 user 记录。2.2 记忆不是越多越好截断、摘要与持久化既然模型只认 messages 数组那是不是把历史上所有对话一股脑塞进去就行不行。每个模型都有上下文窗口限制窗口包含输入和输出超出部分要么被截断要么直接报错。就算不超token 消耗也是成本的直接来源对话越长单次请求越贵。我的经验是做原型阶段用滑动窗口截断就够了。比如只保留最近 5 轮对话更早的内容直接扔掉。这确实是“丢记忆”但大多数应用场景里用户只关心最近几轮内容。真正要长期保存的信息应该抽成笔记或摘要。更进阶的做法是“摘要压缩”每过几轮把前面的对话交给模型总结成一段短文再放进 system 消息里。这样既保留关键事实又不会让 token 无限膨胀。会话持久化也是一个工程问题。如果服务端重启后用户历史全没了体验会很糟糕。最简单的方案是把 messages 序列化后存到数据库的 JSON 字段或 Redis 里每次请求再从存储中恢复。对于 day03 来说这一步可以后置但你必须知道记忆的归宿不该是内存变量而是外部存储。2.3 一个够用的上下文管理封装下面这个封装是我在练习时一直在用的简化版核心就是维护一个deque消息队列自动丢弃最老的对话轮次from collections import deque class MemoryBuffer: def __init__(self, max_rounds5, system_prompt你是一个有用的AI助手): self.system_prompt system_prompt self.history deque(maxlenmax_rounds * 2) def add_user(self, content): self.history.append({role: user, content: content}) def add_assistant(self, content): self.history.append({role: assistant, content: content}) def build_messages(self): return [{role: system, content: self.system_prompt}] list(self.history) def estimate_tokens(self): # 经验估算1个汉字约等于2个token精确值取决于平台的切词方式 return sum(len(m[content]) * 2 for m in self.build_messages())注意deque(maxlen...)的用法当队列满员时最早的元素会自动被弹出这正好符合“保留最近N轮”的需求。实际请求时把build_messages()的返回值作为 messages 参数提交即可。这里的estimate_tokens只是粗略估算用来在请求前判断是否接近窗口上限真要精确统计就调用平台的 tokenizer 接口。说到底上下文管理就是一个“空间换理解”的权衡塞进去的东西越精准模型的注意力越集中。相反上下文越长哪怕没有超限模型也容易忽略埋在中间的关键信息。我在实际对比中发现超过一定长度后模型对早期内容的“回忆”能力会明显下降越靠后的指令权重越大。所以尽量把最重要的约束放在 system 和最新的几条消息里。3. 从“回答问题”到“动手干活”Function Calling的落地细节3.1 原理模型的“调用申请”而不是“实际执行”先纠正一个常见误解Function Calling 并不是模型自己去运行函数。模型依然是文本生成模型它的输出依然是字符串只不过这个字符串被约定成一个结构化的“调用申请”里面包含函数名和参数。你把它解析出来在自己的代码里执行对应函数再把执行结果以tool消息的形式回传给模型让模型根据结果继续生成回复。整个过程可以类比成前台的接待员客户提出需求接待员不亲自干活而是写一张“维修工单”交给后台师傅师傅干完活之后在工单上填写结果接待员再把结果转达给客户。大模型就是那个接待员它负责理解需求、选择工具、组织话术但真正执行的一定是你的代码。为什么要设计成这种间接方式因为直接让模型“假装执行”结果不可控它编造的数值看起来完美实际上完全不可信。只有把执行权拿回到你手里让结果来自真实的数据库查询、API 调用或数学运算应用才具备生产价值。结构化输出的优势在于你不需要费劲解析模型返回的自然语言它直接给你一个 JSON 格式的参数表。3.2 一个最小可运行的Function Calling示例下面是一段非常精简的示例演示定义工具、触发调用、执行函数、回填结果的完整链路。这里我把 LLM 请求封装成call_llm函数实际项目中替换成所选平台的 SDK 即可。import json def get_express_fee(package_weight, distance_km): # 本地模拟真实的计费规则 base_fee 6.0 fee base_fee package_weight * 2.0 distance_km * 0.5 return {fee: round(fee, 2), currency: CNY} tools [{ type: function, function: { name: get_express_fee, description: 计算快递运费需要包裹重量和寄送距离, parameters: { type: object, properties: { package_weight: {type: number, description: 包裹重量单位kg}, distance_km: {type: number, description: 寄送距离单位km} }, required: [package_weight, distance_km] } } }] messages [ {role: user, content: 帮我算一下3公斤的包裹寄80公里要多少钱} ] resp call_llm(messagesmessages, toolstools) msg resp[choices][0][message] tool_calls msg.get(tool_calls) if tool_calls: func tool_calls[0][function] args json.loads(func[arguments]) result get_express_fee(**args) messages.append(msg) messages.append({ role: tool, tool_call_id: tool_calls[0][id], content: json.dumps(result, ensure_asciiFalse) }) final_resp call_llm(messagesmessages, toolstools) print(final_resp[choices][0][message][content])运行后模型会基于函数返回的真实数值生成最终答案比如“3公斤包裹寄80公里运费是 58.00 元”。注意几个关键点tool_calls的 ID 必须原样带回role必须是toolcontent是 JSON 字符串。如果这三个细节不匹配平台会直接报错或拒绝。3.3 工具描述设计决定模型“会不会用”很多人第一次跑通 Function Calling 后会觉得“不过如此”但一旦工具数量变多问题就来了模型经常选错工具或者参数传得乱七八糟。这通常不是模型智商问题而是你的工具描述写得不够“说人话”。工具描述里最重要的部分是description和参数description。一个含糊的“计算运费”远不如“计算快递运费应用于用户询问寄送价格、运费预估等场景”来得准确。参数描述也要明确单位、取值范围、必填与否。模型是靠这些描述来做“意图映射”的描述写得越具体出现幻觉参数的概率越低。我踩过的一个典型坑是工具描述里没有说明距离的单位是“公里”用户说“寄 80 公里”模型却在参数里填了80.0看着没错但另一个工具如果叫“查附近门店”单位是“米”模型就可能把 80 公里直接当成 80 米用。同一个数字在不同工具语境下含义完全不同所以参数描述必须写出单位。还有一个小技巧如果某个参数在不传时可以用默认值兜底就不要把它放在required列表里这能大幅降低模型因为漏参而调用失败的频率。4. Agent雏形一个能自己决定下一步的对话循环4.1 从“听指令”到“自己安排下一步”普通 Function Calling 的流程是用户问一句模型判断要不要用工具如果要用就调用一次然后直接给最终答复。这看起来已经很智能了但真实任务往往需要“多步操作”用户问“帮我规划一个从家出发去公园再回家的路线”你需要先查当前位置、再查公园地址、再规划路线、再算油耗每一步的结果都影响下一步的参数。这就是 Agent 和普通函数调用的本质区别Agent 不假设一步能完成它用一个循环反复“请求模型→解析调用申请→执行工具→回填结果→再次请求模型”直到模型不再发起工具调用为止。你可以把它理解成一个“有眼力见的下属”替你去协调一趟办不完就多跑几趟每一趟的结果都会更新他手里的信息最终给你一个综合答复。4.2 最小Agent循环完整代码与执行流程下面这个run_agent函数只有 30 行左右但它已经具备了 Agent 的核心骨架。我继续沿用快递运费工具并额外注册一个“查询折扣”函数让循环有机会连续调用两次工具。def get_discount(coupon_code): # 模拟查询优惠券接口 return {discount: 5.0, code: coupon_code} tools [ # 这里注册 get_express_fee 和 get_discount 两个工具的完整 schema ] def run_agent(user_input, max_iter5): messages [{role: user, content: user_input}] for step in range(max_iter): resp call_llm(messagesmessages, toolstools) assistant_msg resp[choices][0][message] messages.append(assistant_msg) tool_calls assistant_msg.get(tool_calls) if not tool_calls: return assistant_msg[content] for tc in tool_calls: func tc[function] args json.loads(func[arguments]) if func[name] get_express_fee: result get_express_fee(**args) elif func[name] get_discount: result get_discount(args.get(coupon_code, )) else: result {error: unknown tool} messages.append({ role: tool, tool_call_id: tc[id], content: json.dumps(result, ensure_asciiFalse) }) return 已达最大迭代次数停止执行请检查是否出现循环调用。 print(run_agent(3公斤包裹寄80公里用优惠券 SAVE50 后多少钱))这个函数的退出条件非常清晰如果模型在最新一条assistant消息里没有携带tool_calls就说明它自认为信息已经足够此时它返回的content就是最终答复。如果max_iter耗尽还没结束就强制退出避免无限循环烧钱。4.3 迭代边界与安全护栏防止Agent乱跑Agent 循环看起来简单但实际运行中必须提前设置护栏。第一个护栏是最大迭代次数上面代码里已经体现了。第二个护栏是超时控制因为每次模型调用都有延迟如果一次任务要跑 20 轮用户体验会非常糟糕你需要预估耗时并设置总时长上限。第三个护栏是错误恢复工具执行抛出异常时不要把异常直接丢弃而是作为tool消息内容回传给模型让它知道“工具出错了”这样模型有可能给出替代方案或要求用户修改输入。把异常写进tool消息是一个很实用的小技巧。比如 get_express_fee 里因为参数为负值抛了异常你可以在 except 块里返回{error: 参数非法包裹重量不能为负}。模型读到这个结果后通常会自己修正回复告诉用户“重量不能为负数”而不是束手无策。这正好模拟了人与人协作时“反馈”的价值。当你把上面的循环跑通后恭喜你已经有一只麻雀虽小五脏俱全的 Agent 了。它不依赖任何重型框架只凭一个while循环就实现了“思考→行动→观察→再思考”的闭环。理解了这一层之后再去看各种 Agent 框架或编排工具你就能看懂它们到底在封装什么了。5. day03最容易翻车的五个坑现场复盘与修复5.1 坑一消息顺序错乱模型开始胡言乱语现象调用工具后模型回答的内容跟工具返回的结果完全对不上甚至出现“我不知道你刚才问了什么”的情况。根因通常是tool消息没有紧跟对应的assistant消息或者tool_call_id填错。正确顺序必须是assistant携带 tool_calls→tool携带 tool_call_id 和结果→ 再次请求。中间不能插入 user 消息不能颠倒顺序。修复方式很简单按上面示例的 messages 追加顺序来先 append assistant 消息再 append tool 消息然后再发起请求。如果用了并发执行多个工具要记得把每个 tool 消息的tool_call_id对应到正确的调用上。我就见过有人把所有结果都塞给同一个tool_call_id模型直接被搞晕。5.2 坑二本地重开页面模型秒变“失忆”现象在同一个前端页面里对话 20 轮一切正常刷新页面后模型完全不记得你是谁。根因不是模型坏了而是前端的 messages 状态存在内存变量里刷新即清空后端也没有持久化。修复方向是把消息列表写入 localStorage 或后端数据库每次初始化会话时恢复历史。这个坑特别隐蔽因为开发阶段浏览器不刷新问题不会暴露。一旦部署上线用户刷新页面就丢上下文体验会非常差。所以从 day03 就应该建立“每次请求前从存储中读取历史”的习惯而不是“从内存中直接取”。5.3 坑三必填参数填不齐函数调用连环报错现象工具函数定义里所有参数都是 required但模型经常漏传导致执行时报TypeError。原因很现实用户的问题里根本没有提供那些信息模型只能猜猜不中就干脆不传。解决办法有两种一是把可以推导的参数从 required 里去掉在函数内部设置默认值二是在工具描述里写明“如果用户未提供请先向用户询问”。我建议优先采用第一种。比如运费计算的距离如果用户只说“从上海寄到北京”你可以在描述里写“距离参数必须由经纬度接口计算得出不可由模型凭空估计”。当然最好的设计是让工具函数自己有兜底逻辑模型传什么参数都不至于崩掉。5.4 坑四max_tokens设太小输出被拦腰截断现象模型生成的 JSON 型 tool_calls 被截断JSON 解析直接失败或者最终回答只输出了一半就结束。根因是请求参数里max_tokens设置得太小而模型生成空间不足。这个参数限制的是总输出长度包括函数调用文本和最终答复。解决方法是给 Function Calling 场景预留足够的输出空间。我一般把 max_tokens 设为 1024 以上涉及长文本时设置 2048 或更高。如果平台支持流式输出还可以配合流式来判断是否截断。另外要注意max_tokens 过小不会报错只会悄悄截断这是最容易忽视的地方。5.5 坑五日志缺失调试全靠猜现象Agent 某次调用返回了莫名其妙的结果你却完全不知道模型当初是怎么选的工具、传了什么参数。根因是循环过程没有记录日志。开发阶段不打印中间结果等于闭着眼睛调 Agent。我的习惯是每轮循环都打印五个关键信息当前步骤序号、模型选择的函数名、解析后的参数、工具执行结果、模型本轮完不完整。把这些信息输出到控制台或日志文件能极大缩短排查时间。尤其当模型多次调用工具时中间任何一步出错日志会直接告诉你问题出在哪个环节。这个习惯值回票价。结尾一个不起眼但非常实用的经验最后分享一个小观察。很多人学到这里会把注意力都放在“怎么让模型更聪明”上反复调 prompt、换模型、加复杂工具。但我在实际练习中发现最容易拖垮应用的往往不是模型能力而是工程细节消息顺序、参数校验、错误处理、日志记录、超时设置。模型推理能力再强也架不住代码层面对返回值的不信任和对异常的不设防。所以 day03 真正的收获不是你也拥有一段能跑通的 Agent 代码而是你开始接受一个事实AI 应用开发的重点不在“AI”在“应用”。你在传统后端开发里养成的那些好习惯——边界校验、防御式编程、可观测性、成本控制——在 AI 应用里一样都不能少反而因为模型输出不可控显得更加重要。如果你和我一样是边学边写代码的类型建议把今天这段 Agent 循环保存下来下一步用你自己的业务数据替换掉运费计算函数比如查库存、查订单、算折扣跑通一个对你自己真正有用的场景。等这个循环稳定了基本上就可以开始考虑向量检索、RAG、多 Agent 协作这些进阶话题了。我个人觉得先别急着追新框架把今天的循环吃透比什么都强。