最近一年多,我一直在做各种 Agent 相关的项目,从最初套某个重型框架、堆一堆插件,到后来发现代码越来越难维护、行为越来越不可控,最后彻底转向自己从零写一个极简 Agent。这个转变让我想明白一个道理:Agent 的价值不在于它接入了多少服务,而在于它的决策循环是否清晰可控。这篇文章就围绕“如何实现一个功能齐全但极简的 Agent”来聊。我会直接说清楚我踩过的坑、用过的套路,以及最终沉淀下来的一套极简实现方案。所谓极简,不是把功能砍掉,而是把代码组织到“没有多余零件”的程度:核心循环清晰,扩展工具只写业务逻辑,不触碰框架代码。文中的代码都用 Python 演示,跑通主流程大概只需要一百多行。1. 开局先想明白:你要的“极简”到底是什么1.1 功能齐全的定义——先把骨架定下来很多人一说“功能齐全”,就开始罗列 AutoGPT 式的功能列表:联网搜索、读网页、写代码、操作文件、调用各种 API、多 Agent 协作……但平心静气想想,一个 Agent 真正离不开的功能其实只有四个:推理内核:能根据用户输入和当前状态,决定下一步做什么。一般就是一个大模型 API。工具集:能调用外部能力,比如搜索、计算、读写文件。这是 Agent“动手”的通道。记忆区:能保存对话过程中产生的上下文,让后续决策不“失忆”。控制开关:能限制行为边界,比如最大步数、允许执行的操作白名单。除此之外的东西,都是锦上添花。我见过不少项目把多 Agent 协作、长期记忆、人机交接做成起步标配,结果项目还没跑通,光在框架配置上就折腾了一周。所以我的建议是:先用最少的模块跑通一个闭环,再按需长功能。1.2 极简不等于简陋——一条主循环走天下极简的另一个关键是“结构要瘦”。很多人写 Agent 喜欢画流程图,把分支判断、状态机、中间件层层铺开,代码一看就很“架构”,跑起来却处处是洞。极简 Agent 的核心组织方式,其实就是一条主循环:观察:把用户请求、历史消息、工具返回结果拼成上下文,交给模型。思考:模型输出下一步动作意图,通常用结构化格式(JSON)表达。行动:解析意图,执行对应工具,把结果再次塞回上下文。这条循环可以反复执行,直到模型给出最终答案或触发终止条件。整个 Agent 本质上就是一个while循环,加上一组可注册的工具。这种做法最大的好处是:你不需要引入任何编排引擎,Agent 的每一步行为都透明可见,出了问题也能单步跟踪。我最早用某开源框架时,遇到一次 Agent 反复调用同一个失败工具导致死循环,排查了半天,最终发现是框架内部的 retry 机制和工具异常处理互相冲突。换到极简结构后,这类问题一眼就能看清。2. 核心循环:Agent 的“心脏”该怎么搭2.1 观察—思考—行动的闭环设计先写最核心的推理函数。这个函数负责与模型交互,并返回结构化的“行动意图”。为了让模型更容易输出稳定结构,我通常让模型只输出 JSON,用reason字段说明思考过程,用action字段指定工具名,用params字段携带参数。import json from typing import Callable, Dict, List, Optional class Tool: def __init__(self, name: str, description: str, params_schema: dict, func: Callable): self.name = name self.description = description self.params_schema = params_schema self.func = func def run(self, params: dict): return self.func(**params) class Agent: def __init__( self, llm_func: Callable[[List[dict]], str], tools: Optional[List[Tool]] = None, max_steps: int = 6, ): self.llm_func = llm_func self.tools = {tool.name: tool for tool in (tools or [])} self.max_steps = max_steps def step(self, messages: List[dict]) -> dict: prompt = self._build_prompt(messages) raw_output = self.llm_func(prompt) return self._parse_output(raw_output) def _build_prompt(self, messages: List[dict]) -> str: # 将 messages 序列化为自然语言指令 lines = [] for msg in messages: role = msg["role"] content = msg["content"] lines.append(f"<{role}>: {content}") lines.append("\n请只输出 JSON,格式:") lines.append('{"reason": "思考摘要", "action": "工具名或null", "params": {}}') return "\n".join(lines) def _parse_output(self, raw_output: str) -> dict: # 清理可能的围栏符号 output = raw_output.strip().strip("`").strip() try: return json.loads(output) except json.JSONDecodeError: # 兜底:让模型重新输出 return {"reason": "解析失败", "action": None, "params": {}}这里的llm_func是任何“输入文本、输出文本”的函数。你可以用任意大模型 API,也可以先用一个假的“模拟模型”来测试循环逻辑。把模型调用隔离成这样一个函数,是极简设计的第一道关键接口。2.2 循环控制:怎么防止 Agent 失控有了 step,再把主循环拼出来。我在写 Agent 时最关心的三件事是:什么时候停、参数传错怎么办、模型乱来怎么办。class Agent: # …… 接上文的 __init__ 和 step 方法 … def run(self, user_input: str): messages = [ {"role": "system", "content": "你是助手。请根据上下文使用工具完成任务。"}, {"role": "user", "content": user_input}, ] for step_num in range(1, self.max_steps + 1): result = self.step(messages) action = result.get("action") params = result.get("params") or {} reason = result.get("reason", "") if action is None or action == "null": return result.get("output", "任务结束。") tool = self.tools.get(action) if tool is None: messages.append({ "role": "system", "content": f"工具 {action} 不存在,请重新选择。", }) continue try: tool_output = tool.run(params) # 把工具结果放回上下文,让模型接着思考 messages.append({ "role": "system", "content": f"调用 {action} 返回:{tool_output}", }) except Exception as e: messages.append({ "role": "system", "content": f"调用 {action} 出错:{e}", }) return "达到最大步数,任务未完成。"这段代码里有几个细节值得说明:工具不存在时不直接崩溃,而是把错误信息重新喂给模型,让它自己调整。实测大部分模型能快速换个工具或纠正行为,这也是 Agent 和普通脚本的重要区别——它具备“自我纠错”的空间。工具异常同样返回给模型,而不是抛出异常终止。很多业务场景下,工具失败只是因为临时网络抖动,模型一句“稍后再试”就能解决,没必要让整个 Agent 退出。最大步数是最便宜的保险丝。我习惯设 6 步左右,既够完成多动作任务,又能有效防止无意义循环。你可能会问:为什么不让模型直接输出自然语言,而要走 JSON?因为结构化的意图输出是稳定循环的前提。非结构化文本解析难度大,一个标点差异就可能让分支判断失效;而 JSON 有成熟的解析库,配合 schema 校验能兜住绝大多数解析问题。如果模型确实容易输出不合法 JSON,可以在提示词里给一个“工具调用示例”,然后再加一个 parser 容错层,也就是上面_parse_output的兜底逻辑。3. 工具系统:让 Agent 长出“手脚”3.1 工具注册机制——加一个新功能只要十行代码极简 Agent 的工具系统不需要插件框架,更不需要复杂的依赖注入。核心就是一个注册表:派 name、描述、schema 和函数本体。我用一个装饰器来收集工具:class Agent: def __init__(self, llm_func, max_steps=6): self.llm_func = llm_func self.max_steps = max_steps self.tools = {} def tool(self, name: str, description: str, params_schema: dict): """装饰器:注册一个工具""" def decorator(func): self.tools[name] = Tool(name, description, params_schema, func) return func return decorator用起来是这样的:agent = Agent(llm_func=my_llm, max_steps=6) @agent.tool( name="calculator", description="计算数学表达式的值,例如 '12*7 + 3'", params_schema={"type": "object", "properties": {"expression": {"type": "string"}}, "required": ["expression"]} ) def calculator(expression: str) -> str: return str(eval(expression))在这个设计里,工具函数只需要关注“输入参数、返回字符串”这一个约定。Agent 的 llm_func、消息组织逻辑、循环控制,全部和具体业务解耦。后面再加一个“查询时间”的功能,就是再写一个普通函数的事。这个模式的好处,别的框架也能做到,但极简方案里你能清楚看到“注册”发生的时机:装饰器在类实例创建后立即执行,工具表随即建立,不依赖任何外部配置文件。我比较推荐这种“内部注册”而不是“外部读配置”,因为后者等于给 Agent 增加了一层 YAML/JSON 的间接性,排查问题时还得额外查配置。3.2 消息历史:Agent 和工具的沟通桥梁再往细里说,工具结果如何回到模型视野,是极简 Agent 最容易写“脏”的地方。我见过一些实现,工具结果只在控制台打印,模型根本看不到,导致 Agent 频繁调用同一个工具。正确的做法是:无论工具成功还是失败,把结果以明确的文本形式追加到 messages 里,让模型在下一轮 step 时自然读到。举个例子。用户问“帮我算一下 (2+3)*4,然后告诉我结果”。第一次循环模型输出:{"reason": "用户需要计算,使用计算器", "action": "calculator", "params": {"expression": "(2+3)*4"}}Agent 执行后得到20,message 里追加一条:调用 calculator 返回:20第二次循环模型再读上下文时,看到这条结果,直接输出action: null和最终答案。这个“assistant 输出 JSON → 工具返回 → 系统消息回填”的节奏,是整个极简 Agent 最核心的通信协议。只要这个协议保持稳定,模型的表现就会很稳定。一个小技巧:在用户输入特别长时,不要简单地把一整篇文档塞进 messages,而是让工具把原文“摘要化”,比如用read_webpage工具返回“网页标题 + 前 500 字摘要”。上下文一旦超长,模型要么丢信息,要么干脆理解偏,提前在工具层压缩信息是性价比最高的方案。4. 记忆管理:功能齐全的关键拼图4.1 窗口截断——最简单但有效的短期记忆很多 Agent 跑着跑着,messages 越来越长,直到超过模型上下文限制。极简方案里第一道防线是“窗口截断”:保留最初的 system 提示,保留最近的 N 轮交互,把中间的旧内容直接丢。我通常保留最近 10 轮。代码非常直白:def truncate_messages(messages, keep_k=10): if len(messages) <= keep_k + 1: return messages return [messages[0]] + messages[-keep_k:]注意这里保留了 system 提示,也保留了最近一个工具返回结果——因为当下这轮模型正要基于它做决策。截断要防止“模型正要被提醒的关键信息被提前裁掉”,所以最好在每次 step 前做检查,而不是在消息积累到超限才一次性裁剪。这个“窗口截断”虽然听着土,但应付大多数对话场景完全够用。如果你要的是让 Agent 记得“你半小时前说的偏好”,单靠窗口肯定不够,那就得往下走。4.2 摘要记忆——让 Agent 记住“重要的事”窗口截断丢的是“信息”,摘要记忆丢的是“噪音”。做法是:当消息超过阈值时,让模型把前序内容压缩成一小段摘要,作为 system 提示放在最前面。def summarize_messages(messages, llm_func): content = "\n".join(f"{m['role']}: {m['content']}" for m in messages) prompt = f"请用200字以内概括这段对话的关键信息:\n{content}" summary = llm_func(prompt) return [{"role": "system", "content": f"对话摘要:{summary}"}]跑长任务时,我会在每 5 步之后把已经完成的历史消息合并成摘要,释放上下文空间。这样做的代价是模型会丢失部分细节,所以摘要提示词里我会强调“保留数字、姓名、偏好、待办事项”。记忆管理本质上是“取舍管理”,极简 Agent 不追求无损记忆,够用就好。4.3 长期记忆的极简出路——本地存储别折腾当你同时跑多个会话,或者希望 Agent“跨会话记住用户喜好”时,短期记忆就不够了。极简方案里我推荐一条保守路线:用一个本地的 JSON 文件或 SQLite 表,键是用户 ID 或会话 ID,值是摘要后的记忆文本。import json def save_memory(user_id: str, memory: str, file_path="memory.json"): try: with open(file_path, "r", encoding="utf-8") as f: data = json.load(f) except FileNotFoundError: data = {} data[user_id] = memory with open(file_path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2)读取时,在构建 messages 前把该用户的记忆文本插入 system 提示。如果需要语义检索,再去接一个轻量向量库;一开始不要上向量库,JSON 存储足够你跑通产品验证。很多人一上来就搭向量库,结果发现数据量几百条,检索效果和直接取摘要差不多,白白增加部署复杂性。5. 实操:组装一个能查天气、算数、写文件的极简 Agent5.1 从零搭建的完整代码骨架把前面的组件拼起来,就是一套可扩展的极简 Agent。我在这里给出一个完整可运行的骨架,模型调用用函数占位,你可以替换成任意大模型 API。import json import random from typing import Callable, List, Dict, Optional # ---------- 1. 模拟大模型 ---------- def fake_llm(prompt: str) -> str: # 实际项目中这里换成真实模型调用,比如:return requests.post(api_url, json={...}).text # 为演示,返回一条写死的“决策” return json.dumps({"reason": "演示决策", "action": None, "params": {}}) # ---------- 2. 极简 Agent 核心 ---------- class Agent: def __init__(self, llm_func: Callable[[str], str], max_steps: int = 6): self.llm_func = llm_func self.max_steps = max_steps self.tools: Dict[str, Tool] = {} # ... 前面定义的 step / run / _parse_output / tool 装饰器 ... # ---------- 3. 定义工具 ---------- def define_tools(agent: Agent): @agent.tool( name="get_weather", description="查询一个城市的天气,返回温度、天气状况", params_schema={"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]} ) def get_weather(city: str) -> str: data = { "江州市": "晴,26°C", "临江市": "多云,22°C", "滨海市": "小雨,19°C", } return data.get(city, f"暂无{city}的天气数据") @agent.tool( name="calculator", description="计算数学表达式,例如 '3*7+2'", params_schema={"type": "object", "properties": {"expression": {"type": "string"}}, "required": ["expression"]} ) def calculator(expression: str) -> str: return str(eval(expression)) @agent.tool( name="write_file", description="将内容写入指定文件", params_schema={ "type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"] } ) def write_file(path: str, content: str) -> str: with open(path, "w", encoding="utf-8") as f: f.write(content) return f"成功写入 {path}" # ---------- 4. 跑起来 ---------- if __name__ == "__main__": agent = Agent(llm_func=fake_llm, max_steps=6) define_tools(agent) result = agent.run("你好") print(result)把fake_llm换成真实接口后,这个 Agent 就能处理真实业务。我自己的做法是给fake_llm写几个“剧本”,先测工具调度是否正确,再去接真实模型。这样调试 Agent 本身和调试模型行为就被拆开了,能省不少事。5.2 跑一个完整任务,看 Agent 如何思考与行动以“帮我查一下江州市和临江市的天气,计算温差,把结果写进 notes.txt”为例。真实运行时,模型输出大致会经过以下几步:模型说要查天气,输出action=query_weather,params={"city": "江州市"}。Agent 执行返回“江州市:晴,26°C”,追加到上下文。模型说要查临江市,再次调用天气工具。Agent 看到两个城市温度后,调用calculator计算 26 和 22 的差值。最终模型输出“温差为4°C”,并调用write_file将结果写入文件。这五步在极简循环里是完全一致的代码路径:工具名不同,参数不同,但循环结构没变。真正的 Agent 就是这样一个“反复决策-执行”的过程,没有额外魔法。这里面有个小坑:如果工具返回的数值是字符串“26°C”,直接拿去给模型计算,它很可能搞错单位。所以我的天气工具设计时,就返回了纯数字字段,比如{"city": "江州市", "temp": 26, "condition": "晴"},模型读起来更可靠。工具返回的数据格式要尽量机器友好,而不是人看的好看,这是提高 Agent 成功率的关键之一。6. 踩坑实录与自查清单6.1 我遇到过的五个高频问题问题一:模型输出的 JSON 里,action 名字和注册的工具名对不上。原因往往是 schema 里 description 写得不够清楚,或者工具名起得太抽象。我给工具命名都用“动词+对象”的完整拼写,比如query_weather而不是q1,并在 description 里把“什么场景下用、参数格式是什么”写明白。如果还是对不上,就在工具不存在时把候选工具列表反馈给模型。问题二:上下文越滚越长,模型反而忽略工具结果。这是上下文污染。解决办法是在每条工具返回前加醒目标记,比如【工具执行结果】,并在 system 提示里强调“优先参考最近的工具结果”。我自己的实现会做一个简单处理:把最近一条 tool message 紧贴在用户消息后面,而不是塞在历史末尾。问题三:Agent 在同一工具上反复执行相同参数。一般是模型没意识到自己已经调用过。极简方案里我加了一个“近 3 步动作检查”:如果action和params完全重复,就直接在上下文里追加“你已经请求过该操作,结果已提供,请基于结果继续”。这比单纯限制步数更显智能。问题四:工具内部异常导致整个对话中断。之前我在tool.run里直接raise,结果一次文件写入失败就毁掉了整个会话。后来改成捕获所有异常,并返回一个包含 error 信息的字符串。模型看到错误后往往能换个方式完成任务。工具层要“永不抛异常”,把问题转成信息流,这是极简 Agent 稳定性的重要保障。问题五:模型在需要多步操作时,总想一步到位。有些模型拿到任务后不会依次调用工具,而是直接给出一步结果,或者一次 JSON 里塞多个 action。极简方案的办法是在 system 提示里明确:“每次只能调用一个工具,分步完成,不要尝试一次做多件事。”如果真的需要并行调用多个工具,再额外扩展一个聚合接口,但默认永远串行。6.2 自查清单:你的极简 Agent 有没有“增重”我在每次写完一个 Agent 之后,会拿着下面这些问题过一遍,确认它没有悄悄变重:检查项极简标准增重信号核心循环只有一个 while + step 函数出现多个循环、状态机、分支跳转工具接入装饰器注册一个函数即可需要写配置、注册表、插件系统才能加一个功能模型调用输入字符串,输出字符串引入 SDK 特有的 streaming、agent 协议后才可用上下文管理窗口截断 + 可选的摘要完整向量库、多级记忆系统才起步异常处理工具结果返回错误字符串内部异常层层抛出,依赖外部告警才发现测试方式用 fake_llm 直接跑单测必须启动全套服务才能验证逻辑这套标准不是“技术洁癖”,而是我自己在迁移回极简结构后,明显感受到迭代速度提升:新增工具只需要 10 分钟,定位问题只看一个循环,行为回溯只用打印消息列表。在你动手做自己的极简 Agent 前,我还想提醒一句:第一版千万别急着接最强的模型。先用一个便宜的、响应快的模型跑通闭环,再逐步升级。极简结构最大的好处就是“换模型等于换一个函数”,升级成本极低。等你真的做到这一步,你会回来感谢当初那个“只写一百行代码”的决定。
最近一年多,我一直在做各种 Agent 相关的项目,从最初套某个重型框架、堆一堆插件,到后来发现代码越来越难维护、行为越来越不可控,最后彻底转向自己从零写一个极简 Agent。这个转变让我想明白一个道理:Agent 的价值不在于它接入了多少服务,而在于它的决策循环是否清晰可控。这篇文章就围绕“如何实现一个功能齐全但极简的 Agent”来聊。我会直接说清楚我踩过的坑、用过的套路,以及最终沉淀下来的一套极简实现方案。所谓极简,不是把功能砍掉,而是把代码组织到“没有多余零件”的程度:核心循环清晰,扩展工具只写业务逻辑,不触碰框架代码。文中的代码都用 Python 演示,跑通主流程大概只需要一百多行。1. 开局先想明白:你要的“极简”到底是什么1.1 功能齐全的定义——先把骨架定下来很多人一说“功能齐全”,就开始罗列 AutoGPT 式的功能列表:联网搜索、读网页、写代码、操作文件、调用各种 API、多 Agent 协作……但平心静气想想,一个 Agent 真正离不开的功能其实只有四个:推理内核:能根据用户输入和当前状态,决定下一步做什么。一般就是一个大模型 API。工具集:能调用外部能力,比如搜索、计算、读写文件。这是 Agent“动手”的通道。记忆区:能保存对话过程中产生的上下文,让后续决策不“失忆”。控制开关:能限制行为边界,比如最大步数、允许执行的操作白名单。除此之外的东西,都是锦上添花。我见过不少项目把多 Agent 协作、长期记忆、人机交接做成起步标配,结果项目还没跑通,光在框架配置上就折腾了一周。所以我的建议是:先用最少的模块跑通一个闭环,再按需长功能。1.2 极简不等于简陋——一条主循环走天下极简的另一个关键是“结构要瘦”。很多人写 Agent 喜欢画流程图,把分支判断、状态机、中间件层层铺开,代码一看就很“架构”,跑起来却处处是洞。极简 Agent 的核心组织方式,其实就是一条主循环:观察:把用户请求、历史消息、工具返回结果拼成上下文,交给模型。思考:模型输出下一步动作意图,通常用结构化格式(JSON)表达。行动:解析意图,执行对应工具,把结果再次塞回上下文。这条循环可以反复执行,直到模型给出最终答案或触发终止条件。整个 Agent 本质上就是一个while循环,加上一组可注册的工具。这种做法最大的好处是:你不需要引入任何编排引擎,Agent 的每一步行为都透明可见,出了问题也能单步跟踪。我最早用某开源框架时,遇到一次 Agent 反复调用同一个失败工具导致死循环,排查了半天,最终发现是框架内部的 retry 机制和工具异常处理互相冲突。换到极简结构后,这类问题一眼就能看清。2. 核心循环:Agent 的“心脏”该怎么搭2.1 观察—思考—行动的闭环设计先写最核心的推理函数。这个函数负责与模型交互,并返回结构化的“行动意图”。为了让模型更容易输出稳定结构,我通常让模型只输出 JSON,用reason字段说明思考过程,用action字段指定工具名,用params字段携带参数。import json from typing import Callable, Dict, List, Optional class Tool: def __init__(self, name: str, description: str, params_schema: dict, func: Callable): self.name = name self.description = description self.params_schema = params_schema self.func = func def run(self, params: dict): return self.func(**params) class Agent: def __init__( self, llm_func: Callable[[List[dict]], str], tools: Optional[List[Tool]] = None, max_steps: int = 6, ): self.llm_func = llm_func self.tools = {tool.name: tool for tool in (tools or [])} self.max_steps = max_steps def step(self, messages: List[dict]) -> dict: prompt = self._build_prompt(messages) raw_output = self.llm_func(prompt) return self._parse_output(raw_output) def _build_prompt(self, messages: List[dict]) -> str: # 将 messages 序列化为自然语言指令 lines = [] for msg in messages: role = msg["role"] content = msg["content"] lines.append(f"<{role}>: {content}") lines.append("\n请只输出 JSON,格式:") lines.append('{"reason": "思考摘要", "action": "工具名或null", "params": {}}') return "\n".join(lines) def _parse_output(self, raw_output: str) -> dict: # 清理可能的围栏符号 output = raw_output.strip().strip("`").strip() try: return json.loads(output) except json.JSONDecodeError: # 兜底:让模型重新输出 return {"reason": "解析失败", "action": None, "params": {}}这里的llm_func是任何“输入文本、输出文本”的函数。你可以用任意大模型 API,也可以先用一个假的“模拟模型”来测试循环逻辑。把模型调用隔离成这样一个函数,是极简设计的第一道关键接口。2.2 循环控制:怎么防止 Agent 失控有了 step,再把主循环拼出来。我在写 Agent 时最关心的三件事是:什么时候停、参数传错怎么办、模型乱来怎么办。class Agent: # …… 接上文的 __init__ 和 step 方法 … def run(self, user_input: str): messages = [ {"role": "system", "content": "你是助手。请根据上下文使用工具完成任务。"}, {"role": "user", "content": user_input}, ] for step_num in range(1, self.max_steps + 1): result = self.step(messages) action = result.get("action") params = result.get("params") or {} reason = result.get("reason", "") if action is None or action == "null": return result.get("output", "任务结束。") tool = self.tools.get(action) if tool is None: messages.append({ "role": "system", "content": f"工具 {action} 不存在,请重新选择。", }) continue try: tool_output = tool.run(params) # 把工具结果放回上下文,让模型接着思考 messages.append({ "role": "system", "content": f"调用 {action} 返回:{tool_output}", }) except Exception as e: messages.append({ "role": "system", "content": f"调用 {action} 出错:{e}", }) return "达到最大步数,任务未完成。"这段代码里有几个细节值得说明:工具不存在时不直接崩溃,而是把错误信息重新喂给模型,让它自己调整。实测大部分模型能快速换个工具或纠正行为,这也是 Agent 和普通脚本的重要区别——它具备“自我纠错”的空间。工具异常同样返回给模型,而不是抛出异常终止。很多业务场景下,工具失败只是因为临时网络抖动,模型一句“稍后再试”就能解决,没必要让整个 Agent 退出。最大步数是最便宜的保险丝。我习惯设 6 步左右,既够完成多动作任务,又能有效防止无意义循环。你可能会问:为什么不让模型直接输出自然语言,而要走 JSON?因为结构化的意图输出是稳定循环的前提。非结构化文本解析难度大,一个标点差异就可能让分支判断失效;而 JSON 有成熟的解析库,配合 schema 校验能兜住绝大多数解析问题。如果模型确实容易输出不合法 JSON,可以在提示词里给一个“工具调用示例”,然后再加一个 parser 容错层,也就是上面_parse_output的兜底逻辑。3. 工具系统:让 Agent 长出“手脚”3.1 工具注册机制——加一个新功能只要十行代码极简 Agent 的工具系统不需要插件框架,更不需要复杂的依赖注入。核心就是一个注册表:派 name、描述、schema 和函数本体。我用一个装饰器来收集工具:class Agent: def __init__(self, llm_func, max_steps=6): self.llm_func = llm_func self.max_steps = max_steps self.tools = {} def tool(self, name: str, description: str, params_schema: dict): """装饰器:注册一个工具""" def decorator(func): self.tools[name] = Tool(name, description, params_schema, func) return func return decorator用起来是这样的:agent = Agent(llm_func=my_llm, max_steps=6) @agent.tool( name="calculator", description="计算数学表达式的值,例如 '12*7 + 3'", params_schema={"type": "object", "properties": {"expression": {"type": "string"}}, "required": ["expression"]} ) def calculator(expression: str) -> str: return str(eval(expression))在这个设计里,工具函数只需要关注“输入参数、返回字符串”这一个约定。Agent 的 llm_func、消息组织逻辑、循环控制,全部和具体业务解耦。后面再加一个“查询时间”的功能,就是再写一个普通函数的事。这个模式的好处,别的框架也能做到,但极简方案里你能清楚看到“注册”发生的时机:装饰器在类实例创建后立即执行,工具表随即建立,不依赖任何外部配置文件。我比较推荐这种“内部注册”而不是“外部读配置”,因为后者等于给 Agent 增加了一层 YAML/JSON 的间接性,排查问题时还得额外查配置。3.2 消息历史:Agent 和工具的沟通桥梁再往细里说,工具结果如何回到模型视野,是极简 Agent 最容易写“脏”的地方。我见过一些实现,工具结果只在控制台打印,模型根本看不到,导致 Agent 频繁调用同一个工具。正确的做法是:无论工具成功还是失败,把结果以明确的文本形式追加到 messages 里,让模型在下一轮 step 时自然读到。举个例子。用户问“帮我算一下 (2+3)*4,然后告诉我结果”。第一次循环模型输出:{"reason": "用户需要计算,使用计算器", "action": "calculator", "params": {"expression": "(2+3)*4"}}Agent 执行后得到20,message 里追加一条:调用 calculator 返回:20第二次循环模型再读上下文时,看到这条结果,直接输出action: null和最终答案。这个“assistant 输出 JSON → 工具返回 → 系统消息回填”的节奏,是整个极简 Agent 最核心的通信协议。只要这个协议保持稳定,模型的表现就会很稳定。一个小技巧:在用户输入特别长时,不要简单地把一整篇文档塞进 messages,而是让工具把原文“摘要化”,比如用read_webpage工具返回“网页标题 + 前 500 字摘要”。上下文一旦超长,模型要么丢信息,要么干脆理解偏,提前在工具层压缩信息是性价比最高的方案。4. 记忆管理:功能齐全的关键拼图4.1 窗口截断——最简单但有效的短期记忆很多 Agent 跑着跑着,messages 越来越长,直到超过模型上下文限制。极简方案里第一道防线是“窗口截断”:保留最初的 system 提示,保留最近的 N 轮交互,把中间的旧内容直接丢。我通常保留最近 10 轮。代码非常直白:def truncate_messages(messages, keep_k=10): if len(messages) <= keep_k + 1: return messages return [messages[0]] + messages[-keep_k:]注意这里保留了 system 提示,也保留了最近一个工具返回结果——因为当下这轮模型正要基于它做决策。截断要防止“模型正要被提醒的关键信息被提前裁掉”,所以最好在每次 step 前做检查,而不是在消息积累到超限才一次性裁剪。这个“窗口截断”虽然听着土,但应付大多数对话场景完全够用。如果你要的是让 Agent 记得“你半小时前说的偏好”,单靠窗口肯定不够,那就得往下走。4.2 摘要记忆——让 Agent 记住“重要的事”窗口截断丢的是“信息”,摘要记忆丢的是“噪音”。做法是:当消息超过阈值时,让模型把前序内容压缩成一小段摘要,作为 system 提示放在最前面。def summarize_messages(messages, llm_func): content = "\n".join(f"{m['role']}: {m['content']}" for m in messages) prompt = f"请用200字以内概括这段对话的关键信息:\n{content}" summary = llm_func(prompt) return [{"role": "system", "content": f"对话摘要:{summary}"}]跑长任务时,我会在每 5 步之后把已经完成的历史消息合并成摘要,释放上下文空间。这样做的代价是模型会丢失部分细节,所以摘要提示词里我会强调“保留数字、姓名、偏好、待办事项”。记忆管理本质上是“取舍管理”,极简 Agent 不追求无损记忆,够用就好。4.3 长期记忆的极简出路——本地存储别折腾当你同时跑多个会话,或者希望 Agent“跨会话记住用户喜好”时,短期记忆就不够了。极简方案里我推荐一条保守路线:用一个本地的 JSON 文件或 SQLite 表,键是用户 ID 或会话 ID,值是摘要后的记忆文本。import json def save_memory(user_id: str, memory: str, file_path="memory.json"): try: with open(file_path, "r", encoding="utf-8") as f: data = json.load(f) except FileNotFoundError: data = {} data[user_id] = memory with open(file_path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2)读取时,在构建 messages 前把该用户的记忆文本插入 system 提示。如果需要语义检索,再去接一个轻量向量库;一开始不要上向量库,JSON 存储足够你跑通产品验证。很多人一上来就搭向量库,结果发现数据量几百条,检索效果和直接取摘要差不多,白白增加部署复杂性。5. 实操:组装一个能查天气、算数、写文件的极简 Agent5.1 从零搭建的完整代码骨架把前面的组件拼起来,就是一套可扩展的极简 Agent。我在这里给出一个完整可运行的骨架,模型调用用函数占位,你可以替换成任意大模型 API。import json import random from typing import Callable, List, Dict, Optional # ---------- 1. 模拟大模型 ---------- def fake_llm(prompt: str) -> str: # 实际项目中这里换成真实模型调用,比如:return requests.post(api_url, json={...}).text # 为演示,返回一条写死的“决策” return json.dumps({"reason": "演示决策", "action": None, "params": {}}) # ---------- 2. 极简 Agent 核心 ---------- class Agent: def __init__(self, llm_func: Callable[[str], str], max_steps: int = 6): self.llm_func = llm_func self.max_steps = max_steps self.tools: Dict[str, Tool] = {} # ... 前面定义的 step / run / _parse_output / tool 装饰器 ... # ---------- 3. 定义工具 ---------- def define_tools(agent: Agent): @agent.tool( name="get_weather", description="查询一个城市的天气,返回温度、天气状况", params_schema={"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]} ) def get_weather(city: str) -> str: data = { "江州市": "晴,26°C", "临江市": "多云,22°C", "滨海市": "小雨,19°C", } return data.get(city, f"暂无{city}的天气数据") @agent.tool( name="calculator", description="计算数学表达式,例如 '3*7+2'", params_schema={"type": "object", "properties": {"expression": {"type": "string"}}, "required": ["expression"]} ) def calculator(expression: str) -> str: return str(eval(expression)) @agent.tool( name="write_file", description="将内容写入指定文件", params_schema={ "type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"] } ) def write_file(path: str, content: str) -> str: with open(path, "w", encoding="utf-8") as f: f.write(content) return f"成功写入 {path}" # ---------- 4. 跑起来 ---------- if __name__ == "__main__": agent = Agent(llm_func=fake_llm, max_steps=6) define_tools(agent) result = agent.run("你好") print(result)把fake_llm换成真实接口后,这个 Agent 就能处理真实业务。我自己的做法是给fake_llm写几个“剧本”,先测工具调度是否正确,再去接真实模型。这样调试 Agent 本身和调试模型行为就被拆开了,能省不少事。5.2 跑一个完整任务,看 Agent 如何思考与行动以“帮我查一下江州市和临江市的天气,计算温差,把结果写进 notes.txt”为例。真实运行时,模型输出大致会经过以下几步:模型说要查天气,输出action=query_weather,params={"city": "江州市"}。Agent 执行返回“江州市:晴,26°C”,追加到上下文。模型说要查临江市,再次调用天气工具。Agent 看到两个城市温度后,调用calculator计算 26 和 22 的差值。最终模型输出“温差为4°C”,并调用write_file将结果写入文件。这五步在极简循环里是完全一致的代码路径:工具名不同,参数不同,但循环结构没变。真正的 Agent 就是这样一个“反复决策-执行”的过程,没有额外魔法。这里面有个小坑:如果工具返回的数值是字符串“26°C”,直接拿去给模型计算,它很可能搞错单位。所以我的天气工具设计时,就返回了纯数字字段,比如{"city": "江州市", "temp": 26, "condition": "晴"},模型读起来更可靠。工具返回的数据格式要尽量机器友好,而不是人看的好看,这是提高 Agent 成功率的关键之一。6. 踩坑实录与自查清单6.1 我遇到过的五个高频问题问题一:模型输出的 JSON 里,action 名字和注册的工具名对不上。原因往往是 schema 里 description 写得不够清楚,或者工具名起得太抽象。我给工具命名都用“动词+对象”的完整拼写,比如query_weather而不是q1,并在 description 里把“什么场景下用、参数格式是什么”写明白。如果还是对不上,就在工具不存在时把候选工具列表反馈给模型。问题二:上下文越滚越长,模型反而忽略工具结果。这是上下文污染。解决办法是在每条工具返回前加醒目标记,比如【工具执行结果】,并在 system 提示里强调“优先参考最近的工具结果”。我自己的实现会做一个简单处理:把最近一条 tool message 紧贴在用户消息后面,而不是塞在历史末尾。问题三:Agent 在同一工具上反复执行相同参数。一般是模型没意识到自己已经调用过。极简方案里我加了一个“近 3 步动作检查”:如果action和params完全重复,就直接在上下文里追加“你已经请求过该操作,结果已提供,请基于结果继续”。这比单纯限制步数更显智能。问题四:工具内部异常导致整个对话中断。之前我在tool.run里直接raise,结果一次文件写入失败就毁掉了整个会话。后来改成捕获所有异常,并返回一个包含 error 信息的字符串。模型看到错误后往往能换个方式完成任务。工具层要“永不抛异常”,把问题转成信息流,这是极简 Agent 稳定性的重要保障。问题五:模型在需要多步操作时,总想一步到位。有些模型拿到任务后不会依次调用工具,而是直接给出一步结果,或者一次 JSON 里塞多个 action。极简方案的办法是在 system 提示里明确:“每次只能调用一个工具,分步完成,不要尝试一次做多件事。”如果真的需要并行调用多个工具,再额外扩展一个聚合接口,但默认永远串行。6.2 自查清单:你的极简 Agent 有没有“增重”我在每次写完一个 Agent 之后,会拿着下面这些问题过一遍,确认它没有悄悄变重:检查项极简标准增重信号核心循环只有一个 while + step 函数出现多个循环、状态机、分支跳转工具接入装饰器注册一个函数即可需要写配置、注册表、插件系统才能加一个功能模型调用输入字符串,输出字符串引入 SDK 特有的 streaming、agent 协议后才可用上下文管理窗口截断 + 可选的摘要完整向量库、多级记忆系统才起步异常处理工具结果返回错误字符串内部异常层层抛出,依赖外部告警才发现测试方式用 fake_llm 直接跑单测必须启动全套服务才能验证逻辑这套标准不是“技术洁癖”,而是我自己在迁移回极简结构后,明显感受到迭代速度提升:新增工具只需要 10 分钟,定位问题只看一个循环,行为回溯只用打印消息列表。在你动手做自己的极简 Agent 前,我还想提醒一句:第一版千万别急着接最强的模型。先用一个便宜的、响应快的模型跑通闭环,再逐步升级。极简结构最大的好处就是“换模型等于换一个函数”,升级成本极低。等你真的做到这一步,你会回来感谢当初那个“只写一百行代码”的决定。