1. 从热词里拆出真实需求:Agent到底在解决什么问题
先把结论摆在前面:Agent这个词被炒得很热,但剥开外壳,它要解决的核心问题只有一个——让大模型从"一问一答"变成"能自己跑完一件事"。普通对话式LLM,你问一句它答一句,任务边界完全由你划;而Agent的本质,是给它一个目标,让它自己决定下一步做什么、调用什么工具、什么时候停下来。
我接触Agent开发这两年,最大的感受是:很多人一上来就冲着"框架"去,LangGraph、Swarm、各种编排平台挨个试一遍,结果连最基础的循环机制都没搞明白。热词里那几个词其实已经把Agent的骨架暴露得很清楚了——Agent、LLM、工具调用、上下文、循环机制,这五个词就是一套完整的Agent最小系统。
LLM是大脑,负责推理和决策;工具调用是手脚,负责和外部世界交互;上下文是记忆,决定它每一步能看到什么信息;循环机制是心跳,驱动它一轮一轮往前走。缺任何一个,这东西都跑不起来。你去看那些热词,"agent框架与编排""agent架构""agent安全""ai agent怎么扛并发",全是围绕这五个点衍生出来的工程问题。
这篇文章我打算按我自己搭Agent的顺序来拆:先讲整体设计思路和方案选型,再逐个拆核心模块的实现细节,然后是完整的实操流程,最后把我踩过的坑和排查经验整理出来。适合两类人看——一类是刚搞懂LLM API调用、想往Agent方向走的开发者;另一类是已经在用现成框架、但总觉得"黑盒太多、控制不住"的工程师。我不打算只讲概念,每个环节都会给到能直接抄的参数和代码结构。
2. 内容整体设计与思路拆解
2.1 为什么我不建议一上来就用重框架
市面上的Agent框架大致分三档。最轻的是自己写循环,几十行代码搞定;中间档是LangGraph这类状态机式的编排框架;最重的是各种Agent平台,拖拽配置就能跑。
我的建议很直接:第一个Agent一定要手写循环。原因不复杂——框架帮你封装了循环、上下文管理、工具调度,但一旦出问题,你根本不知道是模型的问题、上下文的问题还是框架的问题。热词里有人问"codex无法发送消息,显示更新agent沙盒",这种问题在自写循环里几乎不会遇到,因为每一步你都能打日志。
手写一遍之后你会彻底理解:所谓Agent,就是一个while循环,每轮把历史消息喂给LLM,LLM返回要么是最终答案、要么是一个工具调用请求,如果是工具调用就执行、把结果塞回消息列表,然后进入下一轮。就这么简单。理解了这层,再去看LangGraph的节点和边,你会发现它无非是把循环拆成了显式的图结构,方便你做分支和持久化。
2.2 方案选型的三个判断维度
选框架还是手写,我一般看三个维度。
任务复杂度。如果任务就是"查个天气再总结一下",手写循环足够。如果任务有明确的多阶段、需要人工介入、需要断点续跑,那LangGraph这类状态机的价值就出来了,它把"状态"显式化了,你可以随时保存和恢复。
可控性要求。金融、医疗这类场景,每一步决策都要可审计,那必须手写或者用足够透明的框架。热词里那个"LLM驱动的公立医院债务风险智能预警"就是典型——这种场景你敢让框架黑盒决策吗?不敢。
迭代速度。如果只是快速验证一个想法,用平台拖拽最快。但一旦要上生产,平台往往成为瓶颈,因为你想改的地方它不让你改。
我自己的习惯是:原型阶段手写,验证可行后再决定要不要引入框架。很多时候验证完发现,手写的版本已经够用了,根本不需要框架。
2.3 上下文才是真正的战场
热词里关于上下文的词特别多:"上下文影响""执行上下文""1m上下文是什么意思""大模型上下文窗口用完了怎么办""上下文长度""上下文学习示例选择策略"。这不是偶然,Agent的成败八成取决于上下文管理。
为什么?因为LLM是无状态的,它每一轮看到的只有你塞给它的消息列表。这个列表里放什么、放多少、怎么排序,直接决定了它的决策质量。放太少,它不知道之前干了什么;放太多,超出窗口或者被无关信息干扰。
我见过太多Agent跑着跑着就"失忆"或者"发疯",追根溯源都是上下文管理没做好。所以下面我会花很大篇幅讲这块,包括窗口快满了怎么办、历史怎么压缩、工具返回结果怎么裁剪。
3. 核心细节解析与实操要点
3.1 LLM作为决策核心:怎么让它稳定输出结构化结果
Agent的第一块基石是让LLM稳定地输出"我要调用哪个工具、参数是什么"。这里最大的坑是模型输出格式不稳定。你让它返回JSON,它有时候给你包一层markdown代码块,有时候加一句"好的,我来帮你调用"。
解决办法有两个层次。第一层是用模型原生的工具调用能力(function calling / tool use),主流模型都支持,你传一个工具schema数组,模型会返回结构化的调用请求。这是最稳的,能用就用。第二层是如果模型不支持或者你要兼容多个模型,那就用提示词约束加解析兜底——明确要求"只输出JSON,不要任何其他文字",然后在代码里做容错解析,比如用正则提取第一个{...}块。
我实测下来,工具schema的描述质量比模型本身更影响调用准确率。工具名要语义清晰,参数描述要写清楚每个字段的含义和格式。比如一个查订单的工具,参数order_id的描述写成"订单编号,格式为纯数字字符串,例如12345678",比只写"订单号"的准确率高一大截。
提示:工具数量超过10个之后,模型选错工具的概率明显上升。这时候要么做工具分组、按场景动态注入,要么在提示词里给出"什么情况用什么工具"的明确指引。
3.2 工具调用:从定义到执行的完整链路
工具调用这条链路,拆开是四步:定义schema、模型决策、执行、结果回填。
定义schema时,我习惯把工具分成三类:查询类(只读,无副作用)、操作类(有副作用,比如发消息、下单)、计算类(纯本地计算)。分类的意义在于——操作类工具一定要加确认机制,不能让Agent自己就把钱花了。
执行环节有个容易被忽略的点:超时和异常处理。工具调用可能失败、可能超时、可能返回一堆垃圾。如果不处理,整个循环就卡死了。我的做法是每个工具调用都包一层,超时设个上限(比如10秒),失败就返回一个结构化的错误信息给模型,让它自己决定重试还是换方案。
结果回填也有讲究。工具返回的原始数据往往很长(比如一个API返回了几KB的JSON),直接塞进上下文会迅速撑爆窗口。所以要做结果裁剪——只提取模型决策需要的字段。比如查天气的API返回了20个字段,你只需要温度和天气状况,那就只回填这两个。
# 工具执行与结果裁剪的简化结构 def execute_tool(tool_name, tool_args): try: raw = TOOL_REGISTRY[tool_name](**tool_args) # 裁剪:只保留关键字段 trimmed = trim_result(tool_name, raw) return {"status": "success", "data": trimmed} except TimeoutError: return {"status": "error", "message": "工具执行超时,请重试或换方案"} except Exception as e: return {"status": "error", "message": f"工具执行失败:{str(e)}"}3.3 上下文管理:窗口快满了到底怎么办
这是我最想展开讲的部分。热词里"大模型上下文窗口用完了怎么办"被反复问,说明这是真痛点。
先明确一个概念:上下文窗口是硬上限,不是软建议。比如模型窗口是128K token,你塞进去130K,要么报错要么被截断。而且就算没超,塞太满也会导致模型"注意力涣散",中间的信息容易被忽略。
我的处理策略是分层:
第一层,控制单轮注入量。工具返回结果严格裁剪,历史消息只保留必要的。系统提示词尽量精简,别写一大段废话。
第二层,历史压缩。当消息列表超过某个阈值(比如占窗口的60%),就触发压缩。压缩不是简单截断,而是让LLM把前面的对话总结成一段摘要,用摘要替换掉原始消息。这样既保留了关键信息,又大幅缩短了长度。
第三层,外部记忆。真正重要的信息(比如用户偏好、任务关键参数)不放在对话历史里,而是存到外部(数据库或文件),需要时再检索注入。这就是RAG思路在Agent里的应用。
注意:压缩历史时一定要保留最近几轮的完整消息,因为模型对最近上下文最敏感。只压缩中间和早期的部分。
3.4 循环机制:什么时候停,比什么时候跑更重要
循环机制的核心问题不是"怎么循环",而是"怎么停"。我见过太多Agent陷入死循环——反复调用同一个工具、反复输出同样的内容、或者在一个无解的问题上打转。
我的做法是设三重刹车:
最大轮数限制。比如最多15轮,到了就强制停止并返回当前状态。这是兜底。
重复检测。如果连续两轮调用了同一个工具、参数也几乎一样,就判定为卡住,中断并提示模型换思路。
无进展检测。如果连续几轮没有产生新的有效信息(比如工具一直返回错误),也中断。
停止条件的设计直接关系到成本和体验。轮数设太少,任务没跑完就停了;设太多,万一卡住就是烧钱。我一般从10轮起步,根据任务复杂度调整。
4. 实操过程与核心环节实现
4.1 环境准备与最小依赖
手写一个Agent,依赖其实很少。核心就两样:一个能调LLM的SDK,一个HTTP库(如果工具有网络请求)。我用Python举例,需要的东西:
pip install openai requests如果你用的是其他模型,换成对应的SDK即可。不需要LangChain、不需要任何框架,先把最小系统跑通。
4.2 第一步:定义工具集
先定义两三个简单工具,别一上来就搞复杂的。我用"查时间"和"算数学"两个工具做演示,一个无副作用、一个有明确输入输出。
import datetime import json def get_current_time(timezone="UTC"): return {"time": datetime.datetime.utcnow().isoformat(), "timezone": timezone} def calculate(expression): # 生产环境千万别用eval,这里仅为演示 try: result = eval(expression, {"__builtins__": {}}, {}) return {"result": result} except Exception as e: return {"error": str(e)} TOOLS = { "get_current_time": get_current_time, "calculate": calculate, } # 给模型看的schema TOOL_SCHEMAS = [ { "type": "function", "function": { "name": "get_current_time", "description": "获取当前时间,当用户询问现在几点、今天日期时使用", "parameters": { "type": "object", "properties": { "timezone": {"type": "string", "description": "时区,默认UTC"} }, }, }, }, { "type": "function", "function": { "name": "calculate", "description": "执行数学计算,当需要做算术运算时使用", "parameters": { "type": "object", "properties": { "expression": {"type": "string", "description": "数学表达式,如 2+3*4"} }, "required": ["expression"], }, }, }, ]注意description我写得比较具体,明确说了"什么时候用"。这是提升调用准确率的关键,别偷懒。
4.3 第二步:写主循环
主循环是整个Agent的心脏。逻辑就是前面说的:喂消息、拿决策、执行工具、回填结果、判断是否结束。
from openai import OpenAI client = OpenAI() def run_agent(user_input, max_turns=10): messages = [ {"role": "system", "content": "你是一个助手,可以使用工具完成任务。任务完成后直接给出答案。"}, {"role": "user", "content": user_input}, ] for turn in range(max_turns): response = client.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=TOOL_SCHEMAS, tool_choice="auto", ) msg = response.choices[0].message # 没有工具调用,说明任务结束 if not msg.tool_calls: return msg.content # 把模型的决策加入历史 messages.append(msg) # 执行每个工具调用 for tool_call in msg.tool_calls: name = tool_call.function.name args = json.loads(tool_call.function.arguments) result = execute_tool(name, args) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result, ensure_ascii=False), }) return "达到最大轮数限制,任务未完成"这段代码不到40行,但已经是一个能跑的Agent了。你可以拿"现在几点了,然后帮我算一下从今天到年底还有多少天"这种需要多步的任务去测,看它怎么一步步调用工具。
4.4 第三步:加上上下文压缩
上面的版本跑长任务会撑爆窗口。加一个压缩函数,在每轮开始前检查消息长度。
def estimate_tokens(messages): # 粗略估算:中文约1.5字符/token,英文约4字符/token total = 0 for m in messages: content = m.get("content") or "" total += len(str(content)) / 2 return total def compress_history(messages, threshold=6000): if estimate_tokens(messages) < threshold: return messages # 保留系统消息和最近4条,中间的做摘要 system_msg = messages[0] recent = messages[-4:] middle = messages[1:-4] if not middle: return messages summary_prompt = "请用简洁的中文总结以下对话的关键信息和已完成的操作:\n" summary_prompt += "\n".join([str(m) for m in middle]) summary_resp = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": summary_prompt}], ) summary = summary_resp.choices[0].message.content return [system_msg, {"role": "system", "content": f"历史摘要:{summary}"}] + recent在主循环开头调用messages = compress_history(messages)即可。阈值根据你模型的窗口大小调整,一般取窗口的50%到60%比较安全。
4.5 第四步:加刹车机制
最后加上防死循环的逻辑。记录每轮的工具调用,检测重复。
def run_agent_with_guard(user_input, max_turns=10): messages = [...] call_history = [] for turn in range(max_turns): messages = compress_history(messages) response = client.chat.completions.create(...) msg = response.choices[0].message if not msg.tool_calls: return msg.content # 检测重复调用 current_calls = [(tc.function.name, tc.function.arguments) for tc in msg.tool_calls] if current_calls in call_history[-2:]: return "检测到重复操作,任务可能陷入循环,已中断" call_history.append(current_calls) # ... 执行工具、回填结果到这里,一个带上下文管理和防死循环的Agent就成型了。总共一百多行,但核心机制全都有了。
5. 常见问题与排查技巧实录
5.1 模型不调用工具,直接瞎编答案
这是最常见的。模型明明有工具可用,却直接凭记忆回答。原因通常是工具描述不够明确,或者系统提示词没强调"必须用工具获取实时信息"。
我的排查顺序:先看工具schema的description是不是太模糊;再看系统提示词有没有明确要求;最后看tool_choice参数,实在不行设成required强制它必须调用工具(但这样它就没法直接回答了,要慎用)。
5.2 工具调用参数格式错误
模型返回的参数JSON解析失败,或者字段类型不对。这个多半是schema里参数描述不够清楚。比如你要求一个整数,但没说明,模型可能传字符串。
解决办法是在参数description里写清楚类型和示例,然后在代码里做类型转换兜底。别指望模型100%听话。
5.3 上下文超限报错
报错信息一般是"context length exceeded"之类。这时候要么触发压缩,要么裁剪工具返回结果。我建议两个都做——压缩是长期策略,裁剪是每轮都要做的。
5.4 Agent跑着跑着开始重复
典型的循环卡死。检查你的重复检测逻辑有没有生效,以及工具是不是一直返回同样的错误导致模型反复重试。如果是工具本身的问题,先修工具。
5.5 并发场景下的状态污染
热词里有人问"ai agent怎么扛并发"。核心问题是:如果你的Agent用全局变量存状态,多个请求同时进来就会串。解决办法是每个请求一个独立的messages列表和状态对象,绝不共享可变状态。如果要用外部存储,用请求ID做隔离。
| 问题现象 | 最可能原因 | 排查动作 |
|---|---|---|
| 不调用工具直接回答 | 工具描述模糊/提示词没约束 | 检查schema和system prompt |
| 参数解析失败 | 参数类型描述不清 | 补充description,加类型转换 |
| 上下文超限 | 历史或工具结果太长 | 开启压缩,裁剪工具返回 |
| 反复调用同一工具 | 无重复检测/工具持续报错 | 加重复检测,修工具 |
| 并发下结果错乱 | 共享了可变状态 | 每请求独立状态对象 |
5.6 几个我踩过的坑
第一个坑是过度依赖模型的"自觉"。早期我总觉得提示词写清楚就行,结果模型该犯错还是犯。后来明白,能用代码约束的绝不用提示词——格式用schema约束,流程用代码控制,提示词只负责语义理解。
第二个坑是工具返回结果不裁剪。有次一个搜索工具返回了整页HTML,直接把上下文撑爆,Agent当场失忆。从那以后我所有工具返回都做字段白名单。
第三个坑是没设最大轮数。有次测试一个任务,Agent卡在循环里跑了上百轮,账单直接起飞。现在我的默认值就是10轮,特殊任务才调高。
6. 从最小系统到生产级Agent的扩展方向
把上面这套跑通之后,你会发现扩展方向很清晰。
加持久化。把messages存到数据库,支持断点续跑。这就是LangGraph那类框架帮你做的事,但你手写也不难。
加多Agent协作。热词里提到Swarm框架的"agent、handoff与上下文变量",本质就是多个Agent之间传递控制权和上下文。你可以先从一个主Agent加几个专职子Agent开始,主Agent负责调度,子Agent负责具体领域。
加评估。热词里有"llm as judge",就是用另一个LLM来评判Agent的输出质量。这在没有标准答案的任务里特别有用,可以自动化跑回归测试。
加安全边界。操作类工具一定要有权限控制和确认机制。热词里"agent安全"不是空话,一个能自主调用工具的Agent,如果工具权限过大,风险是实打实的。
我自己在实际操作中的体会是:Agent这东西,框架能帮你省事,但省不掉理解。你把最小循环、上下文管理、工具调用这三件事亲手实现一遍,再去看任何框架都会觉得通透。反过来,如果跳过这步直接上框架,遇到问题就只能干瞪眼。所以如果你刚开始搞Agent,别急着追新框架,先把那一百多行手写循环跑明白,后面的一切都是在这上面加东西。