恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Agent 开发实战:从上下文窗口、Function Calling 到 MCP 记忆与工具管理
首页
资讯中心
/
Agent 开发实战:从上下文窗口、Function Calling 到 MCP 记忆与工具管理
Agent 开发实战:从上下文窗口、Function Calling 到 MCP 记忆与工具管理
发布时间:2026/10/12 16:24:59
做 Agent 开发这一年多我最大的感受是模型的能力早就不是瓶颈真正卡住你的永远是两个词——记忆和工具。一个 Agent 假如没有记忆上一轮聊完这一轮就翻脸不认人假如没有工具它就只能输出文字查不了天气、读写不了文件、调不了接口。这两件事本质上都指向同一个问题模型在一个有限的上下文窗口里怎么拿到它需要的信息怎么对外部世界产生真实影响。从早年硬编码拼接 Prompt到后来 Function Calling 成为标配再到今天各厂商都在押注的 MCPModel Context Protocol这条演进路线很清晰地说明了一件事记忆和工具正在从每个项目手写一遍变成标准化基础设施。这篇文章不聊概念就聊我实际摸过、踩过、重构过的那些方案。适合正在做 Agent 应用、被上下文越搞越头疼、或者准备接 MCP 但不知道从哪下手的开发者。1. 从上下文窗口说起Agent 的记性到底差在哪1.1 Token 不是无限的窗口的现实约束很多人对上下文窗口的理解是错的。以为窗口大就等于记忆好。这是一个非常昂贵的误解。上下文窗口的本质是模型在生成下一个 Token 时能看到的最大输入范围。它由两部分组成系统提示词、历史对话、工具返回结果、外部检索到的资料以及你当前要模型处理的问题。所有这些加在一起才叫占用窗口。窗口不是只给对话留的任何一个环节撑爆整个请求就会报错或者被静默截断。我实测过一些主流模型的窗口参数。名义上给你 200K Token 的窗口真正稳定可用的长度通常只有一半左右。因为输入越长模型的注意力会越分散中间部分的内容经常被忽略。这个现象在业界叫 lost in the middle。你塞了 50 份文档进去模型真正记住的往往只有开头和结尾中间那 40 份等于白给。所以做 Agent 的第一课就是不要迷信大窗口要假设窗口永远不够用。把窗口当成一块需要精细规划的地皮而不是一个无限大的仓库。1.2 短期记忆、工作记忆与长期记忆的划分我习惯把 Agent 的记忆分成三层对应不同的存储和读取方式。短期记忆就是最近几轮对话的原始内容。它最真实、最完整但也最占空间。如果每轮对话都原样塞进上下文三五十轮之后窗口就满了。工作记忆是当前任务执行过程中的临时状态比如正在写第 3 章第 2 节已经调用了 2 次搜索工具用户偏好的语气是简洁这类信息。它不需要永久保存但必须在任务持续期间随时可取。很多 Agent 框架里把它放在一个独立的 state 对象里而不是塞进对话历史。长期记忆是跨会话持久化的信息比如用户的偏好、历史项目的结论、领域知识库。它的特点是量大、复用频率高但不需要每次全量加载。正确的做法是做成外部存储按需检索。这三层记忆对应三种完全不同的技术方案。短期记忆靠上下文规划工作记忆靠结构化状态管理长期记忆靠向量检索或数据库。把它们混为一谈是 Agent 记忆方案设计失败的最常见原因。2. 记忆怎么存三种主流方案与取舍2.1 原样对话历史简单但不耐用最原始的做法就是把所有对话历史原样拼进 Prompt。代码大概长这样messages [] # 每轮对话都追加 messages.append({role: user, content: user_input}) messages.append({role: assistant, content: assistant_output}) # 请求时全量发送 response llm.chat(messages)这个方案的优势是零成本、零丢失模型的每一次输出都有完整的前因后果。但它有一个致命问题窗口被历史吃光之后新输入进不来。我见过很多团队的第一版 Agent 都是这么写的跑 Demo 没问题一旦真人对聊超过 20 轮就开始报错。后来大家想了个补丁——只保留最近 N 轮。这个 N 到底设多少就成了一个每次都要调的心病。设太小模型忘了前面的关键信息回答问题质量断崖式下跌。设太大窗口又不够用。更麻烦的是这种方式只做截断没有做压缩被丢掉的早期信息里如果有用户的偏好或关键约束模型就再也想不起来了。2.2 摘要记忆与滚动压缩解决历史太长的主流方案是摘要压缩。核心思路是把早期的对话交给模型进行总结提炼出关键事实和结论然后只把摘要留在上下文里原始对话归档到外部存储。def compress_history(messages, max_rounds10): if len(messages) max_rounds: return messages # 取最早的部分进行摘要 older messages[:-max_rounds] recent messages[-max_rounds:] summary llm.summarize(older) # 用户偏好简洁回答已确认采用Python技术栈... return [{role: system, content: f对话摘要{summary}}] recent注意这个摘要不是随便写写要设计摘要的结构。我踩过坑之后总结出一套比较稳的摘要格式关键决策、用户偏好、未完成事项、已确认的事实四类信息分开展示。这样模型在需要时能快速定位而不是在一段散文里翻找。摘要记忆的代价是信息损失。模型总结得再好也不可能把一段对话的所有细节都装进几句话里。所以它适合配合长期记忆一起用摘要进上下文完整的原文向量化后存进知识库等需要细节时再检索出来。这一套组合拳打下来Agent 的记忆力才算勉强够用。2.3 向量化长期记忆与外部存储第三层是长期记忆也是 Agent 能不能跨会话持续进化的关键。我的做法是给每个用户或项目建一个独立的记忆空间用向量数据库存储。流程分三步写入阶段把对话中值得记住的信息用户偏好、项目结论、重要事实抽取出来转成向量存入数据库。检索阶段每次用户发起新对话前把当前问题转成向量去库里做相似度检索取 Top-K 条相关记忆拼进上下文。更新阶段定期把旧的、被新信息覆盖的记忆标记为过期防止记忆库越积越乱。# 写入记忆 memory_text 用户偏好使用简洁的中文回答不喜欢术语 vector embed(memory_text) vector_db.insert(project_iduser_id, vectorvector, textmemory_text) # 检索记忆 query_vector embed(user_input) hits vector_db.search(query_vector, top_k5) for hit in hits: context_parts.append(f[记忆] {hit.text})这里有一个很容易忽略的细节不是所有对话都值得存入长期记忆。我见过最典型的错误是把每一轮对话都做向量化入库结果库越来越大检索出来的东西越来越杂反而污染了上下文。正确做法是让模型先判断这句话值不值得记住或者用规则过滤掉寒暄、临时状态类的内容只保留有长期价值的信息。记忆方案没有银弹。我现在的标准架构是三段式短期记忆用滚动窗口中期用摘要压缩长期靠向量检索。三者的比例不是固定的要看业务场景。客服类场景摘要可以激进一些因为旧对话的价值低复杂任务类场景摘要要保守因为细节可能就是关键。3. 工具从硬编码到 Function Calling3.1 没有工具的模型只是嘴强王者给模型接工具这件事本质上是在解决一个知行合一的问题模型能理解语言但它无法直接执行操作。你问它现在几点了它能编一个时间出来但永远不知道真实时间你让它帮我查一下这个订单的物流它只能说抱歉。要让模型拥有真实能力早期方案是硬编码。写一堆 if-else 去匹配用户意图if 天气 in user_input: result weather_api(city) elif 股票 in user_input: result stock_api(code)这套方案在一些限定场景下能用但遇到复杂表达就崩比如帮我看看明天出门要不要带伞它匹配不到天气关键词直接哑火。而且每加一个新功能就要改代码维护成本极高。后来主流方案变成了 Function Calling。它的核心思路是把模型负责理解意图和程序负责执行函数彻底拆开。模型不直接调函数它只输出一段结构化的 JSON告诉你它想调用哪个函数、参数填什么。3.2 Function Calling 的调用闭环一个标准的 Function Calling 流程是这样的首先你把工具的描述以 JSON Schema 的形式告诉模型[ { type: function, function: { name: get_weather, description: 查询指定城市的实时天气, parameters: { type: object, properties: { city: {type: string, description: 城市名称}, date: {type: string, description: 日期格式YYYY-MM-DD} }, required: [city] } } } ]然后模型在生成回答时如果发现需要查天气它不会直接说我帮你查一下而是输出类似这样的一段内容{ name: get_weather, arguments: {\city\: \上海\, \date\: \2025-01-15\} }你的代码捕获到这个输出执行真实的天气接口调用把结果拼成一条新消息再送回给模型。模型看到结果后才能最终生成面向用户的回答。这一步拼接非常关键。我最开始做的时候漏掉了把工具结果回传给模型这个环节导致模型每次只输出一个 JSON 就停了根本不会继续生成用户要的答案。记住这个闭环模型发起调用 - 程序执行函数 - 结果回传 - 模型生成最终回答。3.3 工具调用的常见翻车现场Function Calling 成熟了很多但用起来依然有不少坑。第一个坑是工具描述质量差。模型的 Function Calling 效果高度依赖你写的 description 是否清晰。你写查询天气和写查询指定城市在指定日期的天气情况包含温度、湿度、风力等级用于回答用户关于出行是否需要加衣的问题后者的触发准确率要高出一大截。原因是模型靠描述来判断什么时候该用这个工具描述里的细节就是它的决策依据。第二个坑是工具返回结果太长。天气接口的原始返回可能是一大段 JSON里面 90% 的字段模型根本用不上。你直接把它塞回上下文既占窗口又容易让模型被无关字段干扰。正确做法是写一层工具后处理逻辑把返回精简成几句话。比如上海1月15日多云5~10℃东风3级体感偏冷。这个精简过程我是在一次线上事故之后才领悟的——那次工具返回了 8KB 的 JSON直接把上下文撑爆了后续几轮对话全部异常。第三个坑是工具选择错误。模型有概率在可用工具中选错。比如你同时给了查询订单状态和申请退款两个工具模型可能在用户只问什么时候发货时错误调用了申请退款。缓解办法是在工具描述里加上触发条件和禁止条件比如仅在用户明确表示要退款时才使用本工具询问进度时使用查询订单工具。4. MCP 协议把记忆与工具做成了标准化接口4.1 MCP 是什么服务器-客户端架构Function Calling 解决了模型怎么调工具的问题但它没解决工具怎么接入的问题。每接一个数据源你都要写胶水代码把对方的 API 包装成 schema 再注册进 Agent。数据源一多胶水代码的量就失控了。MCPModel Context Protocol模型上下文协议就是为了解决这个问题而生的。它的思路非常像 USB 接口每个外设不需要为每台电脑定制接口只要它符合 USB 标准插上就能用。MCP 把 Agent 的工具、数据资源和 Prompt 模板统一成一套标准协议只要数据源实现一个 MCP Server任何支持 MCP 的 Agent 客户端都可以直接接入。MCP 的架构分两层MCP Server负责把外部能力数据库、文件系统、第三方 API、知识库暴露成标准接口。一个 Server 可以提供多个工具也可以提供可被检索的资源。MCP Client运行在 Agent 应用内部负责与 Server 建立连接发现 Server 上的工具列表并把调用请求转发给 Server。我最初接触 MCP 的时候觉得它的架构有点重一次工具调用要经过 Agent 应用 - MCP Client - MCP Server - 真实业务接口多了一层跳板。但实际用下来发现这层跳板非常值因为它把接入这件事从开发问题变成了配置问题。4.2 Resource、Prompt、Tool 三类原语MCP 协议定义了三类核心原语分别对应 Agent 需要的三种能力。Tool 是主动执行的操作对应 Function Calling 里的 function。Agent 判断需要执行动作时调用它。典型场景发送邮件、创建工单、执行 SQL 查询。Tool 在执行后有一个确定的结果返回。Resource 是被动读取的数据对应的是知识而不是动作。Agent 不会主动调用 Resource而是由 Agent 应用在需要时去读取。比如一份产品文档、一张用户画像表、一个数据库视图。Resource 可以支持 URI 寻址被设计成类似文件系统的形式方便检索和定位。Prompt 是预定义的模板用来规范 Agent 的特定行为模式。比如一个做代码审查的 Prompt 模板可以预设好审查角度、输出格式、注意事项。Agent 应用可以直接加载这些模板快速切换不同的工作模式。{ tools: [ { name: write_note, description: 写入一条新的持久化笔记, inputSchema: { type: object, properties: { title: {type: string}, content: {type: string} } } } ], resources: [ { uri: memory://user/preferences, name: 用户偏好, description: 用户的行文风格与表达偏好 } ] }这三类原语覆盖了 Agent 的两个核心需求动手做事靠 Tool动脑思考靠 Resource 和 Prompt。4.3 传输方式stdio 与 SSEMCP 定义了几种传输方式最常用的是 stdio 和 SSE。stdio 模式下MCP Server 以子进程方式运行Agent 应用和它之间通过标准输入输出流通信。这种方式适合本地工具比如文件操作、本地数据库、命令行工具。优点是轻量、安全不需要开端口服务器进程跟着 Agent 应用的生命周期走。SSE 模式则通过 HTTP 进行通信Server 作为一个独立的网络服务运行。这种方式适合远程工具比如部署在一台服务器上的知识库服务、第三方 SaaS 的集成网关。多个 Agent 实例可以共享同一个 Server。有一个我一开始没想明白的点这两种方式对 Agent 应用来说是透明的。同一个 MCP Client既可以通过 stdio 连接本地进程也可以通过 SSE 连接远程服务代码层面几乎不用改只是配置文件里的连接方式不同。这也正是 MCP 的抽象价值所在。5. 实操用 MCP 给 Agent 接上可插拔记忆与工具5.1 环境准备与项目结构理论讲再多不如上手跑一遍。我这边用 Python 搭建了一个最小可用的 MCP 实践项目整体思路是用 MCP Server 封装两类能力——一个带持久化存储的笔记工具Tool一个返回用户偏好的资源Resource再用一个支持 MCP 的客户端框架把它们接入 Agent 的对话流程。项目结构如下agent-demo/ ├── server/ │ ├── note_server.py # MCP Server 实现 │ └── requirements.txt └── client/ └── agent.py # MCP Client Agent 逻辑依赖只需要两个MCP 的官方 SDK以及任意一个大模型 API 的 SDK。注意 MCP SDK 的版本迭代比较快建议锁定你安装时的版本不要直接拉最新版否则 API 可能对不上。5.2 编写一个带存储能力的 MCP Server先写最核心的笔记工具。这个工具要做的事情很简单写入一条笔记并把它追加到本地 JSON 文件里。用 JSON 文件做存储是刻意为之——为了展示 MCP Server 可以封装任意存储后端你想换成 SQLite、对象存储都没问题。# server/note_server.py import json from pathlib import Path from mcp.server.fastmcp import FastMCP mcp FastMCP(note-server) NOTES_FILE Path(./notes.json) def _load_notes(): if not NOTES_FILE.exists(): return [] return json.loads(NOTES_FILE.read_text(encodingutf-8)) def _save_notes(notes): NOTES_FILE.write_text( json.dumps(notes, ensure_asciiFalse, indent2), encodingutf-8 ) mcp.tool() def write_note(title: str, content: str) - str: 写入一条笔记返回写入结果。title 是笔记标题content 是正文。 notes _load_notes() notes.append({title: title, content: content}) _save_notes(notes) return f笔记《{title}》已保存当前共 {len(notes)} 条 mcp.tool() def list_notes() - str: 列出所有笔记的标题和摘要用于回顾历史记录。 notes _load_notes() if not notes: return 暂无笔记 return \n.join( f- {n[title]}: {n[content][:50]} for n in notes )注意到mcp.tool()装饰器标明了这是一个工具函数名、参数名和 docstring 会被自动转成工具 schema。这比手写 JSON Schema 爽得多工具接入成本降了一个数量级。再添加一个 Resource用来暴露用户偏好。这里模拟从配置文件读取mcp.resource(memory://user/preferences) def get_preferences() - str: 返回用户的撰写偏好供 Agent 在生成内容时参考。 return 偏好简洁、务实的表达风格技术术语首次出现时给出解释避免空话套话。启动 Server 的代码很简单if __name__ __main__: mcp.run(transportstdio)5.3 在 Agent 侧配置与调用Server 写好后在 Agent 客户端里接入。我用的是 MCP Client连接方式是 stdio也就是直接拉起刚才那个 Python 进程。# client/agent.py import asyncio from mcp.client.stdio import stdio_client, StdioServerParameters async def main(): server_params StdioServerParameters( commandpython, args[server/note_server.py] ) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: # 初始化连接获取工具列表 await session.initialize() tools await session.list_tools() for tool in tools.tools: print(f可用工具: {tool.name} —— {tool.description})这里的关键环节是初始化握手。MCP Client 启动后会向 Server 发送 initialize 请求Server 返回自己的能力列表Client 才能知道有哪些工具可用。握手成功后把工具列表注入到模型请求里后面的调用闭环跟 Function Calling 一样。模型输出工具调用意图 - Client 转发给 MCP Server - Server 执行真实操作 - 结果回传给模型。整个过程跑下来最直观的感受是新接一个数据源不再需要改 Agent 主代码了。你只需要多启动一个 MCP Server 进程告诉 Client 它的地址然后把它的工具列表并进整体请求就行。这就是可插拔三个字的实际含义。5.4 实测记录与效果观察我在本地实测了几轮记录一下现象。第一次测试我对 Agent 说帮我记一下下周要完成项目结项报告重点是性能测试部分。模型判断需要调用 write_note参数解析为 title项目结项报告待办、content下周完成重点是性能测试部分。Server 正确写入 JSON 文件模型收到结果后生成确认回复。第二次测试我再问我之前记过关于结项报告的笔记吗。模型先调用 list_notes拿到返回的标题列表然后基于列表信息回答你有一条笔记标题是项目结项报告待办。注意中间有一次工具调用但用户感知不到模型自然地把工具的中间结果消化掉了。第三次测试更有意思我故意问我想写一篇技术博客你帮我起个标题。模型没有调用任何工具直接生成回答——但在我把memory://user/preferences这个 Resource 也加载进上下文之后模型生成的标题明显更贴近用户的表达偏好。这验证了一个点Resource 提供的是静态知识它不需要被调用它只需要被看到就能影响模型的输出风格。6. 踩坑实录上下文截断、记忆污染与工具循环6.1 上下文截断导致失忆我踩得最深的一个坑是上下文截断策略设计不当。有一次做客服 Agent对话超过 30 轮之后我把最早的对话直接丢弃。结果用户在 15 轮时说过我是付费用户工单优先级要高30 轮后这条信息被挤掉了Agent 后续把用户当成了普通用户来响应态度和优先级处理全都错了。用户直接投诉。这个问题的根源在于按轮数截断天然是错误的。截断不应该是轮数驱动的应该是信息价值驱动的。我现在的做法是在写入上下文之前先让模型对每条历史信息做一个价值标记——是否包含用户身份信息、是否包含未完成事项、是否包含明确偏好。价值高的内容即使很老也要保留或进入摘要价值低的寒暄可以立即丢弃。6.2 工具返回内容太长撑爆窗口工具调用好做难的是对工具返回内容做瘦身。前面提到过一次因为返回 8KB JSON 撑爆窗口的事故后来我养成了一个习惯每一个工具在返回结果前都先做一次格式化只保留用户真正关心的字段并且用自然语言输出而不是原始 JSON。比如一个查询订单状态的工具原始返回是{order_id: 12345, status: SHIPPED, items: [...], address: {...}, logistics: [...], created_at: ..., updated_at: ..., payment: {...}}我应该把它精简成订单 12345 已发货共 3 件商品预计本周五送达。这段精简后的文本既满足模型回答用户问题的信息需求又节省了大量 Token还减少了模型被无关字段误导的概率。工具瘦身这件事MCP 本身不管它是工具开发者的责任。这也是我反复跟团队强调的一点MCP Server 暴露的接口质量直接决定上层 Agent 的效果上限。接口做得糙模型纵有千般能耐也发挥不出来。6.3 Agent 陷入工具循环工具循环是另一个让人头大的问题。场景是这样的Agent 调用了一个查询工具工具返回查无数据Agent 不死心换个参数又查一次还是没数据它再换个查询条件……如此反复四五次每次都消耗 Token就是不肯停下来直接告诉用户查不到。我分析下来原因通常是两个。一个是工具描述里没有说明空结果的正常含义另一个是模型在必须完成任务的指令下产生了一种执念。缓解办法是在系统提示词里加入兜底指令如果连续两次工具调用都返回空结果或相同结果请停止调用工具直接告知用户当前无法获取有效数据并给出建议。不要反复尝试。还有一个偏门但有效的方案在代码层设置工具调用次数上限。我一般设为 3 次达到上限后强制终止工具循环把已获得的中间结果交给模型做最终总结。这个限制既是成本控制手段也是防止 Agent 行为失控的保险丝。6.4 记忆读写的安全与权限问题最后聊一个很容易被忽视、但生产环境必须面对的议题记忆的权限边界。一个多租户的 Agent 应用里A 用户的记忆绝对不能混入 B 用户的上下文。这听起来是常识但我在代码里见到过直接用一个全局向量库存所有用户记忆、检索时不带用户过滤条件的实现——那是妥妥的数据安全事故哪怕只发生一次信任就没了。我现在的规范是三条第一每个用户的记忆必须有独立的命名空间或租户 ID所有读写操作的数据库查询条件里必须强制带上这个 ID第二工具调用的权限分层比如读取笔记所有用户都能用删除笔记可能需要额外授权第三敏感信息不能明文存入记忆存储要么不存要么脱敏要么加密。模型再聪明也扛不住你把用户的 API Key 明文写进记忆库这种低级失误。安全这块没有捷径只有严格按流程来。凡是涉及用户数据的读写都要过一遍权限检查无论调用方是模型还是人。7. 一点自己的心得做 Agent 这一年半载一个很深的体会是模型的智商是给定的但 Agent 的实用程度完全取决于你给它配的记忆和工具。上下文窗口再大也扛不住不做规划的硬塞工具接口再多也不如每个接口都被精心设计和瘦身过。MCP 的意义在于它把这套复杂度从每个项目内部实现细节变成了团队可共享的标准约定——一个业务系统只要暴露成 MCP Server任何上层 Agent 都能直接接入这省掉的是真正重复劳动的苦工。最后分享一个做 Agent 架构时的小习惯每次新加一个工具或一类记忆我都会问自己一个问题——如果三个不同的 Agent 场景都要用它这个抽象合理吗如果答案是要改说明设计还没到位继续磨。毕竟Agent 的工程化本质就是一层一层变量收纳进边界清晰的抽象里而不是让模型在混乱中靠运气发挥。