
Agent 开发这件事我是从去年冬天开始真正沉下来做的。当时手上的需求很朴素把内部几套零散的业务接口串成一个能自己判断先调谁、后调谁的助手。最开始我写的是硬编码 if-else 编排接口一多就彻底失控二十几个分支互相嵌套改一处要回归一整天。后来把这层决策交给 LangChain 的 Agent 机制让模型自己选工具核心代码从八百多行掉到两百行上下维护成本肉眼可见地降了。这篇笔记就是那段时间的落地记录重点聊智能体开发里最基础、也最容易翻车的一层——Model I/O 加 Agent 的最小闭环。如果你刚开始做 LangChain 框架入门或者写过几个 Prompt 脚本但还没碰过工具调用这篇应该能帮你少走点弯路。1. 先弄清楚 Agent 到底是个什么东西1.1 从一问一答到自己决定下一步普通的大模型调用本质是一次性的映射你给一段输入它回一段输出中间没有思考过程也不会去查任何外部资料。你问它今天杭州穿什么合适它只能凭训练数据里的常识给你一个泛泛的回答因为它根本不知道此刻的温度。这个模式在问答、润色、翻译这类场景里够用但只要任务需要动态信息或者多步操作它就立刻露怯。Agent 的核心变化在于多了一个循环。模型拿到任务后先判断我现在缺什么信息然后决定调用哪个工具去拿拿到结果之后再判断信息够不够下一步做什么直到它认为可以给最终答案为止。这个判断—行动—观察—再判断的过程就是 ReAct 思路的落地形态。打个比方普通调用像你在路边随机问一个路人他只能凭记忆告诉你大致方向Agent 则像你雇了一个会自己查地图、会打电话确认、实在不行还会绕路重走的人。前者给的是一锤子答案后者交付的是一段带自我纠错能力的过程。这个区别决定了后面所有的工程复杂度——你要准备工具、要管理上下文、要处理循环次数、要防死循环这些都是普通调用里不存在的问题。我第一次把工具挂上去时最直观的感受是代码变少了但不确定性变多了。少的是分支判断多的是你得时刻盯着模型会不会乱调工具。这个心态转变很重要后面几节会反复提到。1.2 LangChain 在这条链路里到底管什么LangChain 这些年被吐槽抽象层太厚我刚上手时也有同感一个简单的对话要包好几层类。但真把 Agent 跑起来之后你会发现它解决的是一致性问题不同模型厂商的接口格式、不同工具的参数规范、不同记忆存储的读写方式全都被抹平成统一的抽象。你换一个模型业务代码几乎不用动。它大概分三块。第一块是 Model I/O也就是提示词模板、模型调用、输出解析这条流水线是整条链路的地基。第二块是 Retrieval负责把外部知识接进来做检索增强。第三块是 Agent 与工具编排让模型有能力去调用外部函数。很多人一上来就冲着第三块去结果地基没打牢工具描述写得含糊模型天天调错最后怪框架不行其实是 Model I/O 没理顺。我个人的建议是学习顺序反过来先把 Model I/O 玩熟再碰工具。因为 Agent 的每一次思考本质上都是一次带工具定义的模型调用。你把单次调用调明白了Agent 的行为就变得可预测得多。2. 环境准备把最小可运行闭环搭起来2.1 依赖版本与项目结构LangChain 生态的拆包这几年变化很大早些年一个langchain包什么都有现在是langchain-core、langchain-community、langchain-openai这类分体结构。新手最容易踩的坑就是版本对不上装了个旧版的langchain又装了个新版的分包导入路径直接报错。我的做法是固定版本别追求最新。下面这套组合是我实测比较稳的搭配具体版本号你按自己环境调整pip install langchain0.3,0.4 \ langchain-core0.3,0.4 \ langchain-community0.3,0.4 \ langgraph0.2,0.3项目结构上我习惯按职责分文件夹而不是按页面分agent-demo/ ├── config/ │ └── settings.py # 密钥、模型名、超时等集中管理 ├── tools/ │ ├── weather.py # 每个工具一个文件方便单独测试 │ └── calculator.py ├── prompts/ │ └── system.py # 系统提示词单独放方便迭代 ├── agent/ │ └── builder.py # Agent 组装逻辑 └── main.py这个结构看起来比全部塞进一个文件麻烦但等你工具有十几个的时候就知道好处了。每个工具能单独 import 出来跑单元测试这一点在排查到底是模型调错了还是工具本身有 bug时能省下大量时间。提示密钥千万别写在代码里。用环境变量或者.env加python-dotenv提交仓库前检查一遍.gitignore。我见过不止一次密钥被推到公开仓库的案例。2.2 模型接入的几种方式模型接入主要看两点你的模型是不是兼容 OpenAI 的接口协议以及你是不是需要流式输出。现在国内主流的大模型服务基本都提供 OpenAI 兼容的接口所以用ChatOpenAI这个类改一下base_url和模型名就能通。import os from langchain_openai import ChatOpenAI llm ChatOpenAI( model你的模型名, api_keyos.getenv(MODEL_API_KEY), base_urlos.getenv(MODEL_BASE_URL), temperature0, timeout30, max_retries2, )这里几个参数值得说道。temperature设成 0 几乎是 Agent 场景的默认选择因为你需要的是稳定的工具选择和参数填充不是创意。我试过设成 0.7结果同一个问题它一会儿调天气工具一会儿直接瞎编调试起来非常痛苦。timeout和max_retries是生产环境的保命参数网络抖动时没有重试你的 Agent 循环会直接崩在半路而且崩得很难看中间状态全丢。如果你的模型不兼容 OpenAI 协议就得用对应的集成包比如通义、智谱都有官方集成。抽象层的价值在这里就体现出来了无论底层换谁上层llm.invoke()的调用方式不变。2.3 Model I/O 三件套拆解Model I/O 这条流水线拆开就是三段Prompt 负责把用户输入和系统指令组装成模型能吃的格式Model 负责推理Output Parser 负责把模型输出的文本变成结构化数据。很多人只做前两段第三段直接用字符串然后在后面用正则硬抠这种代码脆弱得不行。先说 Prompt。硬拼字符串是新手最常见的写法问题在于格式一改就到处漏改。用模板把变量抽出来from langchain_core.prompts import ChatPromptTemplate prompt ChatPromptTemplate.from_messages([ (system, 你是一名严谨的数据分析助手只基于给定数据回答不确定时明确说不确定。), (human, 请分析以下数据{data}), ])from_messages的好处是消息角色清晰system 和 human 分开改系统提示词不影响业务模板。而 system 提示词在 Agent 里的分量极重它决定了模型的行为基调后面讲工具时会展开。再说 Output Parser。当你要让模型输出结构化结果时用 Pydantic 定义 schema再配合with_structured_output比手动解析 JSON 稳得多from pydantic import BaseModel, Field class Intent(BaseModel): action: str Field(description要执行的动作只能是 query 或 create) target: str Field(description动作作用的目标对象) structured_llm llm.with_structured_output(Intent) result structured_llm.invoke(帮我新建一条客户记录) print(result.action, result.target)Field里的 description 不是装饰它会被塞进给模型的 schema 里模型就是靠这句话理解字段含义的。所以描述要写得像给人看的说明而不是给自己看的注释。我见过有人写成target: str Field(description目标)模型就经常填错改成动作作用的具体对象名称例如客户姓名或订单号之后准确率立刻上来了。2.4 第一段能跑的代码把上面几块拼起来就是一个最小闭环from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI llm ChatOpenAI(model你的模型名, temperature0) prompt ChatPromptTemplate.from_messages([ (system, 你是一个简洁的助手回答不超过三句话。), (human, {question}), ]) chain prompt | llm | StrOutputParser() print(chain.invoke({question: 解释一下什么是提示词模板}))这个|管道符是 LangChain 表达式语言从左到右依次执行。它的好处是每一段都能单独替换、单独测试比如你想换解析器只改最后一段就行。跑到这里如果通了说明地基没问题可以进入下一步了。3. 给模型装上手脚Tool 与 Agent 的组装3.1 Tool 的定义描述比实现重要工具是 Agent 的手脚但决定模型能不能正确使用手脚的是工具的说明书——也就是名称、参数说明和 docstring。我踩过最大的一个坑就在这里一个查询订单的工具我当时的 docstring 只写了查询订单结果模型面对帮我看看上周那笔退款到账没时根本不知道该不该调它最后它选择直接编了个答案。正确的写法是把用途、输入格式、边界条件都写清楚from langchain_core.tools import tool tool def query_order(order_id: str) - str: 根据订单号查询订单的当前状态。 适用场景用户明确提供了订单号想了解发货、退款、签收等状态时。 输入要求订单号必须是纯数字字符串长度 18 位。 不适用如果用户没给订单号或问的是商品库存请改用其他工具。 # 实际查询逻辑 return f订单 {order_id} 状态已发货关键在于不适用这句。模型在选择工具时是排除法加匹配法混合的你告诉它什么情况别用这个工具比只告诉它什么时候用效果好得多。同理工具功能之间要尽量不重叠。如果你有两个工具都能查订单模型会反复横跳这时候应该合并或者明确分工。参数类型也要用标注str、int、list[str]这些类型提示会转成 schema模型据此生成合法参数。用 Python 的 typing 或者 Pydantic 都行但别不写不写的话模型给的参数格式会非常随机。3.2 从 Chain 到 Agent 的写法演进理解了工具之后组装 Agent 有两种主流写法我按新旧顺序说。老一点的写法是create_tool_calling_agent加AgentExecutorfrom langchain.agents import AgentExecutor, create_tool_calling_agent from langchain_core.prompts import ChatPromptTemplate prompt ChatPromptTemplate.from_messages([ (system, 你是一个助手需要外部信息时调用工具不要凭空编造。), (human, {input}), (placeholder, {agent_scratchpad}), ]) agent create_tool_calling_agent(llm, [query_order], prompt) executor AgentExecutor( agentagent, tools[query_order], max_iterations5, return_intermediate_stepsTrue, verboseTrue, ) result executor.invoke({input: 帮我查一下订单 123456789012345678 的状态})这里有个必须理解的细节agent_scratchpad这个占位符。它是 Agent 的草稿纸用来放历史轮次的我调了什么工具、拿到了什么结果。没有它模型每一轮都会失忆反复调同一个工具。很多人复制代码时把这一行漏了然后发现 Agent 在死循环其实问题就出在这儿。max_iterations是防死循环的硬闸必须设。我一般设 5 到 8超过这个轮次还没出结果说明任务本身或者工具描述有问题让它停下来报错比无限转下去好。新一点的写法是直接用 LangGraph 的预置函数from langgraph.prebuilt import create_react_agent app create_react_agent( modelllm, tools[query_order], prompt你是一个助手需要外部信息时调用工具。, ) result app.invoke({messages: [(user, 查一下订单 123456789012345678)]}) print(result[messages][-1].content)这种方式更简洁状态管理由 LangGraph 接管中间消息也保留得更完整。如果你是新项目我建议直接从这个入口开始老写法主要是为了读懂存量代码。3.3 工具调用的完整循环与中间态Agent 一次执行到底发生了什么我把中间过程拆开看步骤谁在做做了什么1框架把系统提示、用户输入、工具 schema 打包发给模型2模型判断需要调用query_order输出工具名和参数3框架拦截工具调用请求实际执行 Python 函数4框架把函数返回值作为工具消息追加到对话里5模型读取工具结果生成自然语言最终答案第 3 步是最容易出意外的地方因为工具函数是你的代码它会抛异常、会超时、会返回 None。所以我强烈建议每个工具内部都包一层 try-except把异常转成对模型友好的文字描述tool def query_order(order_id: str) - str: 根据订单号查询订单状态。 try: data real_api_call(order_id) return f订单状态{data[status]} except TimeoutError: return 查询超时请稍后重试。 except Exception as e: return f查询失败原因{str(e)[:100]}为什么要把异常转成文字而不是直接抛因为直接抛会让整个 Agent 循环崩掉前面所有轮次的上下文全丢。而转成文字之后模型收到的是查询超时它可以自己决定是重试、换个工具还是告诉用户稍后再试。这个设计让 Agent 有了相当的容错能力。verboseTrue一定要开着调试它会把每一步的模型输入输出、工具调用参数和结果全打出来。我排查问题时全靠这个比断点还好使。4. 记忆让 Agent 记得上一轮说了什么4.1 短期记忆的实现方式默认情况下Agent 每次调用都是无状态的。你上一句说查北京的天气下一句说那上海呢它会一脸茫然因为那指代什么它不知道。短期记忆就是解决这个的。最轻量的做法是手动维护消息列表每次把历史消息一起传进去。但对话一长token 消耗会爆炸而且模型的注意力会被稀释。所以实用做法是配合裁剪策略from langchain_core.messages import trim_messages trimmer trim_messages( max_tokens2000, strategylast, token_counterllm, include_systemTrue, )这段配置的意思是最多保留 2000 token 的消息超出时从最早的消息开始丢但 system 消息永远保留。include_systemTrue很关键把系统提示词裁掉的话模型的行为约束就没了。如果用的是 LangGraph 的预置 Agent它内置了 checkpointer 机制你只需要传一个 thread_idfrom langgraph.checkpoint.memory import MemorySaver app create_react_agent( modelllm, tools[query_order], checkpointerMemorySaver(), ) config {configurable: {thread_id: user_001}} app.invoke({messages: [(user, 我上次问的订单到哪了)]}, config)同一个 thread_id 下的对话会自动串起来换一个 thread_id 就是全新会话。这个设计我认为比手动维护列表优雅很多生产环境把MemorySaver换成数据库版的 checkpointer 就能持久化。4.2 长期记忆与检索结合短期记忆管的是这次对话长期记忆管的是这个用户历来是什么情况。两者解决的不是同一个问题别混为一谈。长期记忆的常见实现是向量检索把历史对话、用户偏好、业务知识切片后存进向量库需要时按语义相似度召回。这部分属于检索增强的范畴接入方式和短期记忆完全不同。我一般的分工是——用户身份、偏好、关键结论走长期记忆当前任务的上下文走短期记忆。这里有个我踩过的坑一开始我把所有历史对话都塞进向量库结果召回出来的内容噪音极大经常把半个月前的无关对话捞出来干扰当前判断。后来改成只存结构化摘要比如用户 A 常用收货地址是 X用户 A 上个月咨询过退款噪音一下就降下来了。注意记忆不是越多越好。每多召回一段内容都意味着更多的 token 和更多的干扰。宁可少召回几条高相关的也别一次性灌进去一大段。5. LangChain 和 LangGraph 到底怎么选5.1 定位差异这两个东西经常被拿来比较其实它们的定位不一样。LangChain 更像一个组件库提供模型、提示词、工具、检索这些积木LangGraph 则是把这些积木按图的方式串起来用节点和边描述流程支持条件分支、循环、并行和状态持久化。一个直观的类比LangChain 给你的是零件和一箱乐高你按需要拼LangGraph 给你的是带轨道和信号灯的流水线流程本身是显式的。简单任务用 AgentExecutor 就够了一旦流程里出现如果 A 就走到 B否则回到 C这种需要反复循环判断的逻辑图编排的优势就出来了。我个人的判断标准是看流程能不能画出来。如果一个任务的步骤能用一张图说清楚那用 LangGraph 写会更可控如果流程完全由模型自由决定那预置 Agent 更省事。5.2 什么时候该上 LangGraph有三个信号出现时我会切到 LangGraph。一是需要严格的多阶段流程比如先分类意图再检索再生成最后审核每一阶段用不同模型或不同提示词。用 AgentExecutor 也能硬凑但控制粒度很粗。二是需要人工介入节点比如金额超过一定阈值的操作要停下来等确认。图编排里加一个中断节点就行AgentExecutor 做这个会很别扭。三是需要状态持久化和断点续跑。长任务跑到一半失败能从上个检查点继续而不是从头再来这在生产环境里价值很大。需要说明的是这两者不是替代关系。LangGraph 里的节点完全可以是 LangChain 的 chain工具定义也复用 LangChain 的封装。所以不存在学了新的旧的就没用这种情况我的建议是两套都了解按任务复杂度选。6. 实操中踩过的坑与排查清单6.1 报错与排查速查下面这张表是我这两个月攒下来的基本覆盖了新手会撞到的大部分问题现象常见原因排查方向模型反复调用同一个工具缺agent_scratchpad占位符检查提示词模板是否含该变量Agent 调用工具但不给最终答案工具返回内容太长或格式混乱精简返回值只给必要字段参数格式经常错工具参数没写类型标注补str/int等类型提示报 tool not found工具名和注册名不一致统一用tool装饰的函数名输出 JSON 解析失败没用结构化输出直接抠字符串换with_structured_output循环到上限被强制中止max_iterations太小或工具描述含糊先调大观察 verbose 日志偶尔超时没设timeout和max_retries两个参数都补上排查有一个通用套路先把verboseTrue打开看模型每一步的原始输入输出。九成的问题在日志里一眼就能看出来剩下那一成是工具本身的 bug单独把工具函数拎出来测就行。千万别一上来就怀疑框架我最早也这样浪费了好几天。6.2 成本、延迟与稳定性Agent 的 token 消耗比普通调用高得多因为每一轮循环都要把完整历史重新发一遍。一个五轮的工具调用token 消耗可能是单次调用的六七倍。这不是框架的锅是 Agent 这个范式的固有成本。降成本我一般做三件事。一是把工具返回值压到最小只给模型真正需要的字段别把整个 API 响应原样返回。二是控制历史长度用前面的裁剪策略。三是能不用工具的场景就不用比如纯知识问答完全没必要上 Agent。延迟方面主要来自串行的模型调用轮次。能并行调用的工具尽量并行LangGraph 对并行节点支持得不错。另外流式输出虽然不减少总时间但能显著改善体感用户看到字一个个蹦出来等待焦虑会低很多。稳定性上我的经验是预设失败路径比优化成功路径更重要。工具会挂、模型会抽风、网络会抖Agent 必须能优雅地告诉用户这次没查成而不是抛一个堆栈就结束了。超时、重试、异常兜底这三样一个都不能少。最后分享一个小技巧开发阶段我会准备一组固定的测试问题每次改完工具描述或者提示词就跑一遍用最朴素的方式对比输出。这比凭感觉判断好像变好了靠谱得多也是我从手写分支时代留下来的习惯。Agent 的不确定性确实更强但只要你把可观测性做足它就没那么难驯服。