
最近我在剥一个叫 pi-agent 的教学向 Agent 项目。它不是一个生产级框架代码量不大但把 Agent 开发里最容易绕晕的部分——模型决策、工具调用、上下文维护、主循环控制——用最直白的方式摆在台面上。剥完之后我按它的思路手写了一个 mini agent从“只会聊天”变成“能自己决定调计算器、记待办、整理文本”的小助手。这篇文章就是完整的拆解和复现记录适合刚接触大模型应用开发、想搞清楚 Agent 到底在做什么的读者也适合已经有聊天机器人经验、但没动手写过工具调用的同学。先把结论说在前面所谓 Agent核心不是一个更聪明的模型而是一个“模型 工具 循环”的结构。模型负责把任务翻译成指令工具负责真正干活循环负责把结果送回去让模型继续判断直到任务完成。把这个结构写清楚你的 Agent 就已经能跑起来了。1. 拆掉样板之前先看清 Agent 的核心构成1.1 从“会聊天”到“会干活”差在哪普通的对话机器人拿到一个问题通常只会生成一段文字作为回答。比如你问“帮我算 256 乘 3.14 再乘 2”它可能给你一个大概的数字也可能算错因为大模型的强项是语言生成不是精确计算。Agent 的做法不一样它不直接回答而是自己决定“当前这个任务需要调用一个计算器工具”于是输出一条结构化的调用指令程序收到指令后真正执行计算再把计算结果返回给模型模型看到结果后继续组织后续步骤。这个差别非常重要。聊天机器人是被动的应答器Agent 是一个主动的任务执行者。它能“干活”的本质就是把模型不擅长的、需要确定性执行的环节拆出去交给外部工具来处理同时保留模型的推理能力来调度这些工具。这就像你请了一个助理助理不必自己会算所有账但要知道什么时候该按计算器、什么时候该翻通讯录、什么时候该把最终结论拿给你。1.2 pi-agent 为什么值得当作样板我选择拆 pi-agent有两个原因。第一它足够小。整个项目核心代码只有两个文件左右的量级没有微服务、没有消息队列、没有复杂的 Agent 编排框架一眼能看完。第二它故意保留了“原始感”。工具调用没有过度封装模型返回的 JSON 直接走解析、匹配、执行你能清楚看到每一步数据是怎么流动的。这种原始感对学习特别重要因为很多商业 Agent 平台把细节藏得太深使用者只会在界面上拖节点根本不知道底层发生了什么。当然pi-agent 也暴露了很多弱点不支持并发、没有持久化记忆、工具结果一旦很长就会撑爆上下文。这些恰恰是宝贵的学习材料。把它从 demo 变成可维护系统的过程就是你真正理解 Agent 开发的过程。1.3 动手前需要准备什么如果你完全从零开始也不慌。你需要的基础并不多会写 Python至少能看懂函数、装饰器、dataclass理解 JSON 格式因为模型和程序之间靠 JSON 通信有大模型 API 的使用经验或者至少知道 Chat Completion 接口的请求参数是什么样子一次调用大模型接口大概需要准备模型名、消息列表、参数配置。不需要先学会 LangChain 之类的高层框架恰恰相反我建议先自己手动拼一遍这条链路。只有亲手拼接过你才会明白那些框架替你省掉的到底是什么以及未来遇到问题该去哪个环节排查。2. pi-agent 里的四个零件模型、工具、记忆与循环2.1 大脑模型不是直接“说答案”而是“下指令”pi-agent 第一个关键设计是约定模型必须输出一种固定格式的 Action。它不再返回自然语言段落而是返回一段 JSON{type: call_tool, name: calculator, arguments: {expression: 256*3.14*2}}任务完成时模型输出{type: finish, output: 计算结果是 1607.68}为什么要这样强制因为程序需要一个稳定的协议来理解模型的意图。自然语言表达同一个意思可以有几十种说法解析起来很容易出错而 JSON 结构固定字段明确程序可以非常可靠地拿到“工具名”和“参数”然后路由到对应的执行函数。这里有个容易被新手忽略的细节模型输出 Action 之后程序不能把这个 Action 直接丢给用户而是要把“模型已经决定调用哪个工具”作为一个 assistant 消息记录下来同时把工具执行结果作为一个 tool 消息记录下来。只有这样做模型在下一轮才能真正“看见”它刚才的决策和工具的结果否则它就是一个瞎指挥的裸循环。2.2 工具挂在循环上的外挂能力工具是 Agent 真正接触外部世界的地方。pi-agent 里维护了一个工具注册表每个工具包含四个信息名字、描述、参数格式、执行函数。模型在决策时会先看到所有工具的名字和描述就像你眼前放着一张工具清单然后它判断当前该用哪个。为什么要单独把“描述”和“参数格式”抽出来因为模型是靠这些文本信息来做选择的。一个工具如果只有函数没有描述模型根本不知道它在什么场景下能用如果参数格式不写清楚模型就会自己瞎猜参数名比如该传expression的时候传了value函数一执行就报错。所以工具描述写得越好Agent 的调度准确率越高这跟给说明书差不多。在我的 mini agent 里工具注册长这样register_tool( namecalculator, description计算数学表达式例如 256*3.14*2, parameters{ type: object, properties: { expression: {type: string, description: 要计算的数学表达式} }, required: [expression], }, ) def calculator(expression: str) - str: allowed set(0123456789-*/(). ) if not set(expression).issubset(allowed): return 错误表达式包含非法字符 try: return str(eval(expression, {__builtins__: {}}, {})) except Exception as e: return f计算失败{e}这里必须强调用eval只是为了教学演示生产环境绝对不能直接对所有表达式求值应该用表达式解析库或者更严密的数值计算方案。评论区也有人会问“是不是所有工具都能随便注册”答案是否定的工具越强大风险边界就越要清晰后面我单独讲安全。2.3 记忆上下文到底存在哪里存多长Agent 的记忆不是一个神秘的模块本质上就是消息列表。pi-agent 做了最朴素的实现把 system 提示词、用户输入、模型 Action、工具结果全部拼在一个列表里每次调用模型时整段发送。但随着对话变长这个列表会越来越大。工具返回一个十几 KB 的表格模型再聪明也会被拖慢甚至会超过接口的上下文上限。所以记忆管理的核心问题不是“怎么存”而是“怎么丢”。我在复现时采用了两个策略工具结果截断只保留前几百个字符超出的部分提示模型“结果过长已截断请尝试用更精确的工具操作”当消息超过一定数量时把最早的非关键消息压缩成一句摘要替换掉原始内容。你可以把上下文理解成一张桌子。桌子就那么大你不可能把一仓库的资料全堆上去。Agent 要做的是把最相关的、最新的材料放在桌子上旧材料归档到抽屉里等需要时再拿出来。这个“抽屉”是长期记忆的核心可以用向量数据库也可以先用本地文件应付。2.4 执行循环所有零件咬合的那根轴把这些零件串起来的是一个 while 循环。pi-agent 的主循环大致如下组装当前消息列表调用模型得到一个 Action如果 Action 是finish跳出循环返回最终输出如果 Action 是call_tool根据名称找到工具执行把结果作为 tool 消息追加到列表回到第二步接着问模型“结果你已经看到了接下来怎么做”。这个循环听上去简单但它是 Agent 的灵魂。模型每一次决策都依赖前一次工具返回的结果循环不断把“结果”反馈给“决策者”直到决策者认为任务已经完成。没有这个循环你拥有的只是一个“会调用一次工具的机器人”有了这个循环它才是一个能基于结果连续推进任务的 Agent。我在复现时踩过的第一个坑就是忘记设置最大步数。模型在一个任务上反复调用同一个工具整整转了十几轮才被我用 CtrlC 打断。所以无论你多信任模型都要给循环一个硬性上限这是 Agent 开发的第一条安全底线。3. 手写 mini agent消息结构、工具注册与主循环怎么做3.1 先定义干净的消息结构和 Action代码从数据结构开始。消息不能只存文本因为工具调用需要关联 ID工具结果需要标明来自哪个工具。我参照 pi-agent 的设计用 dataclass 定义了这样几个基础类型from dataclasses import dataclass, field from typing import Any, Optional, Callable, Dict, List dataclass class Message: role: str # system / user / assistant / tool content: str name: Optional[str] None # 工具名role 为 tool 时使用 tool_call_id: Optional[str] None dataclass class Action: type: str # call_tool / finish name: str arguments: Dict[str, Any] field(default_factorydict) output: str 为什么 tool 消息要额外带name和tool_call_id因为如果工具返回值是“计算失败”这样一句话模型无法区分这句话来自计算器还是来自待办列表。带上工具名模型至少知道报错是从哪里冒出来的带上调用 ID未来如果要同时执行多个工具程序可以精确回填结果。Action 里type字段是关键。我把“要调用工具”和“任务结束”分成两个显式类型而不是让模型直接输出一句话说“我完成了”。这样程序判断终止条件时只需要检查type finish逻辑非常干净不容易出现模型写了一大段话但程序不知道要不要退出的情况。3.2 工具注册表用描述信息换模型决策再定义一个Tool容器然后在模块层维护一张全局注册表dataclass class Tool: name: str description: str parameters: Dict[str, Any] func: Callable[..., str] TOOLS: Dict[str, Tool] {} def register_tool(name: str, description: str, parameters: Dict[str, Any]): def decorator(fn): TOOLS[name] Tool( namename, descriptiondescription, parametersparameters, funcfn, ) return fn return decorator这样写的好处有两个一是工具函数本身可以保持普通函数的样子单元测试时直接调用它就行不用关心 Agent 循环二是注册信息集中在一个表里生成提示词时可以把这张表自动序列化给模型看。注册函数的 parameters 其实是一份 JSON Schema。模型厂商的接口普遍支持这种格式会在生成 tool call 时主动校验参数结构减少乱传参数的情况。如果接口不支持也没关系程序内部一样可以用**arguments调函数失败时再把异常信息返回给模型修正。我额外加了一个finish工具设计成“当任务完成时调用”register_tool( namefinish, description任务已经完成结束整个流程并输出最终结论给用户, parameters{ type: object, properties: { output: {type: string, description: 最终要展示给用户的回答} }, required: [output], }, ) def finish(output: str) - str: return output这是一个很重要的设计不要指望模型“意识到”该结束了。你要给它一把明确表示“结束”的钥匙告诉它“想结束时请用这个工具”。如果你不提供它就会把任务做下去或者自作主张输出一段聊天文本循环无法收敛。3.3 模型调用先把消息序列化再把返回值解析成 Action模型调用部分需要封装成函数输入消息列表输出一个 Action。因为不同平台接口差异很大我留一个llm_chat函数你在自己的环境里替换成真正的请求即可def llm_chat(messages: List[Message]) - Action: # 伪代码把 messages 转换成你所用模型接口的请求结构 # 假设已经拿到了模型的文本 resp_text resp_text your_llm_request(messages) return parse_action(resp_text)真正的重点在parse_action。模型返回的文本可能带有 Markdown 代码块、前后空白、甚至混着一点解释文字。我用了一个非常宽容的解析函数import json def parse_action(text: str) - Action: text text.strip() if text.startswith(): # 去掉 json 或 包裹 if \n in text: text text.split(\n, 1)[1] if text.endswith(): text text[:-3].strip() try: data json.loads(text) except json.JSONDecodeError: # 尝试提取第一个 { 到最后一个 } 之间的内容 start text.find({) end text.rfind(}) if start -1 or end -1 or end start: raise ValueError(f无法从模型输出中解析 JSON: {text}) data json.loads(text[start:end 1]) return Action( typedata.get(type, ), namedata.get(name, ), argumentsdata.get(arguments, {}), outputdata.get(output, ), )这段解析逻辑看似简单却解决了 Agent 开发中至少一半的“玄学问题”。很多初学者看到模型返回了错误格式就怀疑模型不行其实只要做一层容错解析大部分问题都能稳定解决。3.4 主循环一个可以直接跑通的最小案例下面是把所有零件拼起来的主循环。我特意加上了异常捕获即使工具内部抛错也不会让整个 Agent 崩溃SYSTEM_PROMPT 你是一个通过工具完成任务的 Agent。 你可以使用的工具和说明如下 {TOOL_PROMPT} 当需要执行操作时请只输出一个 JSON Action {type:call_tool,name:工具名,arguments:{...}} 当任务完成时请只输出一个 JSON Action {type:finish,output:最终回答} 不要输出多余文字。 def build_system_prompt() - str: desc [] for name, tool in TOOLS.items(): desc.append(f- {name}: {tool.description}) return SYSTEM_PROMPT.format(TOOL_PROMPT\n.join(desc)) def run_agent(user_input: str, max_steps: int 10) - str: messages [ Message(rolesystem, contentbuild_system_prompt()), Message(roleuser, contentuser_input), ] for step in range(max_steps): action llm_chat(messages) if action.type finish: return action.output if action.type ! call_tool: # 模型没有输出合法指令时给一次纠错机会 messages.append(Message( roleassistant, content看起来你刚才没有给出可执行指令请重新输出 JSON Action。 )) continue tool TOOLS.get(action.name) if tool is None: messages.append(Message( roletool, nameaction.name, contentf错误不存在名为 {action.name} 的工具。可选工具{, .join(TOOLS)}, )) continue try: result tool.func(**action.arguments) except Exception as e: result f工具执行失败{e} messages.append(Message( roleassistant, contentjson.dumps({ type: call_tool, name: action.name, arguments: action.arguments, }, ensure_asciiFalse), )) messages.append(Message( roletool, nameaction.name, contentresult[:500], # 截断工具的返回 )) return 达到最大步数任务未完成。为了跑通整个链路注册两个示例工具和finish后输入一句话试试帮我计算 128 * 4.5然后把结果记为一条待办“今天确认计算结果”模型第一步大概率输出调用calculator工具返回576.0循环把结果喂回模型模型看到数字后认为还需要“记待办”于是调用add_todo工具确认已添加最后模型输出finish主循环终止。整个过程就是一次标准的 Agent 决策推进。从这个最小案例可以看到Agent 的能力上限由工具决定协调能力由提示词和循环决定稳定性则靠解析、异常处理和步数上限来兜底。先跑通这个循环再去加记忆、加并发、加更复杂的工具才不会手忙脚乱。4. 参照 pi-agent 实操时最容易踩的坑与排查方法4.1 模型没有按 JSON 格式输出怎么办我在最初跑的时候模型经常输出类似“好的我来帮你计算请稍等计算结果是 576.0”这样的文本而不是 JSON Action。原因通常是系统提示词里的约束不够强或者没有给出清晰的示例。解决方法是把提示词改成“你只允许输出 JSON除此之外什么都不要输出”并在提示词里给出一个完整的例子。解析层再做好容错把 Markdown 代码块剥掉把文本中第一个{和最后一个}之间的内容取出来解析。如果单纯靠提示词还不行就需要检查模型厂商接口是否支持结构化输出。有些接口有专门的 JSON mode 或者工具调用参数能让模型返回的格式稳定很多。不要一个方案试了两次失效就换框架先看看是不是提示词和接口配置的问题。4.2 工具结果太长把上下文塞爆这是个非常现实的问题。我写过一个搜索类工具返回内容动辄几千字塞进消息列表后第二次调用模型明显变慢小上下文模型直接报错。后来我学乖了所有工具结果在返回之前先做一次统一裁剪只保留前 500 个字符。如果结果真的很重要可以让模型自己选择进一步调用更精确的工具去获取细节而不是一次把所有信息推给它。另一种更优雅的做法是把工具结果分区归纳。比如搜索引擎的结果可以去掉 HTML 标签摘出标题、摘要、链接三件套数据库查询结果可以只保留前 10 行并附上“共 100 行”的提示。这样模型拿到的信息密度更高决策也更准确。4.3 工具参数总是传错尤其是字段名和类型模型输出{name: calculator, arguments: {value: 11}}而我的工具期望的是expression于是执行失败。这个问题看起来是模型的问题其实是我工具定义不完整。参数名称要起得足够具象比如expression比value好city_name比text好参数描述里要写清楚取值范围和格式如果某些参数只能选固定值在 JSON Schema 里用enum列出来。我在调用工具之前还会做一个简单的参数校验def safe_call_tool(tool: Tool, arguments: Dict[str, Any]) - str: # 强制检查 required 字段 required tool.parameters.get(required, []) for key in required: if key not in arguments or arguments[key] is None: return f错误缺少参数 {key} # 把参数值统一转为字符串避免类型不匹配 return str(tool.func(**arguments))这样即使模型传参不够完美程序也不至于抛异常中断整个循环而是把错误信息返回给模型让它在下一轮修正。这个“错误作为输入继续决策”的模式是 Agent 和普通编程最大的区别错误不是终点而是决策链路的一部分。4.4 陷入重复调用工具的怪圈有一次我让它“查一下某城市天气再总结”它反复调用天气查询工具每次返回同样的结果却始终不执行finish。这是因为模型触发了循环偏好它觉得“再查一次更稳妥”。我加了两个保险措施最大步数限制从 10 改到 8同时在系统提示词里写明“如果最近三轮已经使用过同一个工具请立刻中止并输出 finish”。另外我还会记录最近一次 Action 的哈希值。如果连续两次 Action 完全相同就向消息列表里注入一句警告“你已经执行过这个操作结果没有变化请换一种方式或直接结束。”这个反馈能非常有效地把模型从死循环里拉出来。4.5 常见问题速查表现象可能原因排查方法处理建议模型输出普通文本提示词没有约束成 JSON查看原始返回内容强化系统提示词增加 JSON 示例解析 JSON 失败返回带 Markdown 代码块或解释文字打印完整返回文本实现宽容解析剥掉代码块工具找不到工具名拼写不一致打印 Action 里的 name注册表自动生成工具列表放进提示词参数缺少必填项JSON Schema 不清晰检查上传的 parameters 定义补充 required 和字段描述上下文超长工具结果未截断打点记录消息总长度统一截断工具结果压缩旧消息死循环缺少终止手段看日志中的重复 Action设置最大步数检测重复 Action工具报错中断未捕获异常栈信息被吞用 safe_call_tool 包住异常返回给模型这张表是我调试 Agent 时最常翻的清单。很多问题从表面看是“模型不听话”深挖一下十有八九是数据结构、提示词、异常处理这三层里的某一个没站稳。5. 从能跑到能用的距离状态持久化、安全与测试5.1 状态与会话让 Agent 记住上一个任务我的 mini agent 目前是无状态执行函数一返回消息列表就丢了。如果要做成一个能持续使用的助手至少要引入会话 ID 和消息持久化。最简单的做法是把消息列表序列化成 JSON 文件按会话 ID 存放每次新请求把历史消息加载进来继续追加。更复杂一点的可以接一个向量数据库把每轮的工具结果存成向量需要回忆时做相似度搜索。我个人体会是不要一开始就上向量库。先用文件缓存把“会话持久化”这件事跑通你才会意识到哪些信息值得存长期记忆哪些只是临时上下文。过早引入抽象存储反而会让调试变得很困难。5.2 工具安全的边界Agent 的能力越大越要小心。允许它执行任意 shell 命令、读写任意文件、访问任意网站等于把一把万能钥匙交给了不可完全信任的决策者。我在自己的项目里加了三条底线工具白名单只能调用注册表里明确列出的函数绝不支持动态导入或任意命令操作确认删除、覆盖、发送等有副作用的行为在执行前先输出一段确认信息给用户由用户授权输入校验所有工具参数在进入内部逻辑前做类型和内容校验。比如计算器只接受白名单字符文件工具只允许读取指定目录下的文件。这不是过度防御。大模型可能被恶意提示词诱导也可能因为上下文里的偶然信息而做出危险操作。把工具当作一个独立的安全边界权限最小化是对用户负责。5.3 可观测性记录每一次决策而不是等到出错再猜Agent 的决策链路很长出错时如果没有日志你真的不知道模型在哪个环节犯了傻。我会给主循环加一个简单的 trace 结构trace [] # 每次循环都追加一条记录 trace.append({ step: step, action: action, tool_result_preview: result[:200], })最终把 trace 渲染成可读文本或者直接打到日志文件里。调试时只需要看最后几步就知道模型是“第一次工具调用就错了”还是“算错了数字”还是“明明有结果却不结束”。我自己有个小习惯凡是模型给出的 Action我都会原样记录凡是工具返回的内容我只记录前 200 个字符。这样日志既保留了决策上下文又不会被海量工具结果淹没。5.4 测试与评估怎么判断 Agent 真的变聪明了Agent 不像普通函数那样能断言“输入 11 输出 2”它是一个随机决策过程。所以我在本地上维护了一组固定的测试任务比如“计算 3 个数字的平均值并记到待办”、“把一串英文标题全部转为大写”、“查询不存在的工具时能优雅退出”。每次改动提示词或工具代码我都会跑一遍这组任务记录完成率和平均步数。更细致的做法是拆成两个指标任务完成率有多少测试用例最终输出了可接受的finish结果决策浪费率平均每个任务多少步才结束步数越多说明模型绕的路越远。我见过一个 Agent 能完成任务但每次都要调用 20 步工具这其实不可用。通过减少提示词里冗余信息的干扰、提升工具描述的清晰度能让它在 5 步以内结束同样任务。这个优化过程比单纯改模型参数重要得多。以我拆 pi-agent 这段时间的实际体感如果你只打算做一件事就先复现第三部分那个最小循环。不用追求第一时间接数据库、接向量库、接各种 Agent 框架先让一个模型在一个循环里自己决定“要不要用工具、用完后再怎么办”你会比任何文档都更快理解 Agent 开发的核心。之后每加一个工具、每处理一类错误都是在这个循环上长肌肉。等你能熟练控制这个循环了再看那些复杂的 Agent 平台、编排框架会发现它们再怎么包装骨子里依然是那四个零件模型、工具、记忆、循环。