1. 为什么我要参照 pi-agent 手搓一个 Agent
市面上讲 Agent 开发的文章,十个里有八个上来就甩 LangChain 的initialize_agent,三行代码跑通一个"智能体",然后告诉你这就是 Agent 开发。我照着跑过,确实能跑,但跑完之后脑子里全是问号:它到底怎么决定调哪个工具的?那个 ReAct 循环是在哪一层转起来的?为什么有时候它明明该调工具却直接编了个答案?这些疑问,光靠调库是解不开的。
所以我给自己定了个规矩:想真正搞懂 Agent,就得参照一个足够小、足够透明的实现,自己从零撸一遍。pi-agent 就是这样一个适合拿来当参照的对象——它没有把 ReAct 循环藏在框架深处,而是把"思考-行动-观察"这条主线明明白白摊在你面前。这篇文章就是我把这个过程完整记录下来的产物,包含架构拆解、核心循环实现、工具注册、记忆管理、容错处理,以及我踩过的那些坑。
这篇内容适合谁看?如果你已经会调大模型 API,能写基本的 Python,但对 Agent 的内部机制还是一团浆糊,那这篇就是写给你的。如果你已经在用 LangChain 或 Dify 做应用,但总觉得"知其然不知其所以然",那这篇也能帮你把底层那层窗户纸捅破。我不会假设你懂什么高深概念,但我会假设你愿意动手敲代码。
先说清楚一个前提:Agent 这个词现在被用得很泛。有人管一个带工具调用的聊天机器人叫 Agent,有人管能自主规划多步任务的系统叫 Agent。我这里讨论的是后者——一个能基于 ReAct 范式,自主决定"下一步该想什么、该做什么、该观察什么"的循环系统。核心就三个词:Agent、ReAct、AgentLoop。把这三个词吃透,Agent 开发的门就推开了一半。
2. Agent 核心架构拆解与方案选型
2.1 一个 Agent 到底由哪几块拼成
在动手之前,我习惯先把一个系统的骨架画清楚,不然写着写着就会乱。参照 pi-agent 的结构,一个最小可用的 Agent 拆下来是这么几块:
- LLM 推理核心:负责"思考",也就是根据当前上下文决定下一步动作。它是整个系统的大脑,但注意,它本身是无状态的,状态得我们自己管。
- 工具集(Tools):Agent 能调用的外部能力,比如搜索、计算器、读写文件。每个工具都有名字、描述、参数 schema。
- AgentLoop 主循环:把"思考-行动-观察"串起来的引擎,是整个 Agent 的心脏。
- 记忆/上下文管理:维护对话历史、工具调用记录、中间结果,决定每一轮往 LLM 里塞什么。
- 解析器(Parser):把 LLM 输出的自然语言解析成结构化的"动作指令",这一步是最容易翻车的地方。
这五块里,LLM 是现成的,工具是业务相关的,真正体现 Agent 工程水平的是主循环、解析器和记忆管理这三块。pi-agent 的价值就在于它把这三块写得足够简单,简单到你一眼能看懂每一行在干嘛。
我选 ReAct 而不是别的范式(比如 Plan-and-Execute),理由很直接:ReAct 的循环粒度最细,每一步都能看到 LLM 的原始输出,调试起来最直观。Plan-and-Execute 是先规划再执行,中间隔了一层,出问题不好定位。对于学习和理解 Agent 本质来说,ReAct 是更好的起点。
2.2 ReAct 范式的本质:让模型学会"边想边做"
ReAct 这个词是 Reasoning + Acting 的缩写,核心思想一句话:让模型在每一步都先输出一段思考(Thought),再输出一个动作(Action),然后系统执行动作拿到结果(Observation),把结果喂回去继续下一轮。
为什么这个范式有效?因为纯推理的模型容易"想当然",纯行动的模型又缺乏规划。ReAct 把两者交替起来,Thought 负责规划和纠错,Action 负责获取真实信息,Observation 负责把真实世界的反馈拉回模型视野。这就好比一个人做菜,不能光在脑子里想(容易漏步骤),也不能闷头就切(容易切错),得"想一下-切一刀-看一眼"循环着来。
一个典型的 ReAct 交互长这样:
Thought: 我需要知道今天北京的天气,应该调用天气查询工具。 Action: get_weather Action Input: {"city": "北京"} Observation: 北京今天晴,气温 5-15 度。 Thought: 我已经拿到了天气信息,可以回答用户了。 Final Answer: 北京今天晴天,气温 5 到 15 度,适合出门。这里有个关键点很多人会忽略:Thought 不是给用户看的,是给模型自己看的。它的作用是让模型把推理过程外化,从而在下一轮能"看到自己刚才想了什么",保持推理的连贯性。这也是为什么 ReAct 比直接让模型输出答案更可靠——它把隐式的推理变成了显式的文本,模型自己就能顺着往下走。
2.3 为什么不用现成框架,非要自己写
这个问题我被问过很多次。我的回答是:框架是用来提效的,不是用来替代理解的。在你还没搞懂 AgentLoop 怎么转的时候用框架,你只是在调 API,不是在开发 Agent。
具体来说,自己写一遍能获得三个框架给不了的东西:
第一,你会被迫想清楚解析器怎么写。框架帮你把 LLM 输出解析成工具调用了,但你没意识到这一步有多脆弱。LLM 可能输出格式不对、可能多输出一段解释、可能把 JSON 写错。自己写一遍,你才知道要在 prompt 里怎么约束格式,在解析时怎么做容错。
第二,你会被迫处理循环终止条件。框架帮你判断什么时候停,但你没想过:模型一直不输出 Final Answer 怎么办?工具一直报错怎么办?循环次数超了怎么办?这些边界条件才是 Agent 稳定性的关键。
第三,你会被迫设计上下文裁剪策略。多轮循环下来,上下文会越来越长,token 会爆。框架帮你截断了,但你没意识到截断策略直接影响 Agent 的表现。自己写,你才会认真思考"哪些历史该留、哪些该丢"。
所以我的建议是:先自己撸一个最小 Agent,跑通 ReAct 循环,然后再去用框架。这时候你看框架的源码,会有种"哦,原来它就是这么干的"的顿悟感。
3. 核心细节解析与实操要点
3.1 Prompt 设计:Agent 的"行为准则"全在这
Agent 的表现好不好,七成看 prompt。ReAct 的 prompt 有几个必须写清楚的部分,我一个个说。
第一,角色和任务定义。要明确告诉模型"你是一个能使用工具的智能助手,通过思考和行动来解决问题"。这句话看着废话,但它决定了模型的输出风格。
第二,工具清单和格式。每个工具的名字、用途、参数必须列清楚。我见过有人只写工具名不写参数格式,结果模型瞎编参数。格式建议用固定模板,比如:
可用工具: - search(query: str): 搜索互联网获取信息 - calculator(expression: str): 计算数学表达式 - get_weather(city: str): 查询城市天气第三,输出格式约束。这是最关键的一步。必须明确规定模型每一步只能输出 Thought + Action,或者 Thought + Final Answer,不能自由发挥。我一般会在 prompt 里给出 few-shot 示例,让模型照着格式来。
第四,停止条件。明确告诉模型"当你认为已经能回答用户问题时,输出 Final Answer: 你的答案"。没有这句话,模型可能一直循环下去。
这里有个实操心得:prompt 里的格式约束要用"必须"而不是"建议"。我试过用"建议按以下格式输出",结果模型十次里有三次不听话。改成"你必须严格按照以下格式输出,否则系统无法解析",听话率立刻上去了。模型对强约束的响应明显更好。
3.2 工具注册:让 Agent 知道"自己能干什么"
工具注册这块,核心是设计一个统一的数据结构,让 Agent 能方便地知道"有哪些工具、每个工具怎么调"。我参照 pi-agent 的做法,用一个字典来管理:
TOOLS = { "search": { "description": "搜索互联网获取信息", "params": {"query": "搜索关键词"}, "func": search_impl }, "calculator": { "description": "计算数学表达式", "params": {"expression": "数学表达式,如 2+3*4"}, "func": calc_impl } }这个结构的好处是,生成 prompt 时可以直接遍历它拼出工具清单,执行时可以直接按名字查到函数。工具的描述要写得具体且带例子,比如"计算数学表达式"就不如"计算数学表达式,支持加减乘除和括号,例如 (2+3)*4"来得清楚。描述越具体,模型选错工具的概率越低。
还有一个容易踩的坑:工具的参数类型要明确。如果参数是数字,就在描述里说清楚"传入数字而非字符串"。我有一次写了个计算器工具,参数没标类型,模型传了个"2+3"字符串进来,结果 eval 的时候报错。后来在描述里加了"expression 为字符串形式的数学表达式",问题就解决了。
3.3 解析器:整个 Agent 最脆弱的一环
解析器负责把 LLM 的自然语言输出,解析成结构化的动作。这一步是整个 Agent 最容易出问题的地方,因为 LLM 的输出本质上是不可控的。
我的解析策略是正则 + 容错。先用正则匹配Action:和Action Input:这两个标记,提取出工具名和参数。如果匹配失败,就认为模型输出了 Final Answer,或者格式错误。
import re import json def parse_output(text): # 先看是不是最终答案 final_match = re.search(r"Final Answer:\s*(.+)", text, re.DOTALL) if final_match: return {"type": "final", "content": final_match.group(1).strip()} # 再看是不是动作 action_match = re.search(r"Action:\s*(\w+)", text) input_match = re.search(r"Action Input:\s*(.+)", text, re.DOTALL) if action_match and input_match: tool_name = action_match.group(1).strip() raw_input = input_match.group(1).strip() # 尝试解析 JSON,失败则当字符串处理 try: params = json.loads(raw_input) except json.JSONDecodeError: params = {"input": raw_input} return {"type": "action", "tool": tool_name, "params": params} # 都没匹配上,当作格式错误 return {"type": "error", "content": text}这段代码看着简单,但每个分支都对应一种真实情况。Final Answer优先判断,是因为模型有时候会在输出最终答案前还带一句 Thought,得先抓住答案。JSON 解析失败时降级成字符串,是因为模型经常不按 JSON 格式传参,直接传个裸字符串。返回 error 类型,是为了让主循环能处理格式错误,而不是直接崩掉。
注意:解析器一定要做容错,绝对不能假设 LLM 输出永远符合格式。我踩过的最大的坑就是早期版本没做容错,模型偶尔多输出一句"好的,我来帮你查一下",正则就匹配失败了,整个 Agent 直接卡死。
3.4 记忆管理:上下文不是越长越好
多轮循环下来,上下文会迅速膨胀。每一轮的 Thought、Action、Observation 都要塞回去,几轮下来 token 就爆了。所以记忆管理是必须的。
我的策略是保留系统 prompt + 最近 N 轮完整记录 + 早期记录的摘要。具体来说:
- 系统 prompt(工具清单、格式约束)永远保留,这是 Agent 的"宪法"。
- 最近 5 轮的完整 Thought/Action/Observation 保留,保证推理连贯。
- 更早的记录,如果超过阈值,就压缩成一句话摘要,比如"之前已经查询了北京天气,结果是晴天"。
为什么不直接全丢?因为早期的一些关键信息(比如用户最初的需求)丢了会导致 Agent 跑偏。为什么不全部保留?因为 token 成本扛不住,而且太长的上下文反而会稀释模型的注意力。
这里有个经验值:单次 Agent 任务的上下文,控制在 4000 token 以内比较稳。超过这个数,模型开始"忘事",而且响应变慢。如果你的任务确实需要长上下文,那就得考虑用向量检索来做记忆召回,而不是全塞进 prompt。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
动手之前先把环境搭好。我用的是 Python 3.10,依赖就两个:一个 HTTP 客户端用来调 LLM API,一个用来做工具实现。不装任何 Agent 框架,纯手写。
pip install requestsLLM 我用的是通用的 chat completions 接口,你换成任何一家都行,只要支持标准的 messages 格式。关键是拿到 API endpoint 和 key,配到环境变量里:
export LLM_API_KEY="your_key_here" export LLM_API_URL="your_endpoint_here"提示:不要把 key 硬编码在代码里,用环境变量。我早期图省事写死在代码里,结果不小心提交到仓库,只能连夜换 key,血的教训。
4.2 主循环 AgentLoop 的完整实现
这是整个 Agent 的心脏,我把它拆成几个函数来写,逻辑更清晰。
首先是调用 LLM 的函数:
import os import requests def call_llm(messages, temperature=0.0): resp = requests.post( os.environ["LLM_API_URL"], headers={"Authorization": f"Bearer {os.environ['LLM_API_KEY']}"}, json={ "model": "your-model-name", "messages": messages, "temperature": temperature }, timeout=60 ) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"]注意temperature=0.0,Agent 场景下我们要的是稳定和可复现,不是创意。温度调高会让模型输出更"飘",格式更容易出错。
然后是主循环:
def run_agent(user_query, max_steps=10): messages = [ {"role": "system", "content": build_system_prompt()}, {"role": "user", "content": user_query} ] for step in range(max_steps): # 1. 思考:调用 LLM llm_output = call_llm(messages) messages.append({"role": "assistant", "content": llm_output}) # 2. 解析输出 parsed = parse_output(llm_output) # 3. 如果是最终答案,结束 if parsed["type"] == "final": return parsed["content"] # 4. 如果是动作,执行工具 if parsed["type"] == "action": tool_name = parsed["tool"] if tool_name not in TOOLS: observation = f"错误:工具 {tool_name} 不存在,可用工具:{list(TOOLS.keys())}" else: try: observation = TOOLS[tool_name]["func"](**parsed["params"]) except Exception as e: observation = f"工具执行出错:{str(e)}" # 5. 把观察结果喂回去 messages.append({ "role": "user", "content": f"Observation: {observation}" }) continue # 6. 格式错误,提示模型重试 messages.append({ "role": "user", "content": "你的输出格式不正确,请严格按照 Thought/Action/Action Input 或 Thought/Final Answer 的格式重新输出。" }) return "达到最大步数限制,任务未完成。"这段代码有几个设计点值得展开说。
第一,max_steps 是必须的。没有这个上限,模型可能陷入死循环,一直调工具不停。我设的是 10,一般任务 3-5 步就结束了,10 步是安全垫。
第二,工具不存在时不是直接报错退出,而是把错误信息作为 Observation 喂回去。这样模型能"看到"自己选错了工具,下一轮自己纠正。这就是 Agent 的"自主容错"——不是系统帮它兜底,而是把错误变成反馈让它自己修。
第三,工具执行异常也捕获了。工具内部报错(比如除零、网络超时)不应该让整个 Agent 崩掉,而是变成 Observation 让模型知道"这个动作失败了",它可以选择换个方式。
第四,格式错误时给的是明确的纠正提示。不是简单说"格式错了",而是把正确格式再强调一遍。实测这样模型纠正的成功率更高。
4.3 系统 Prompt 的完整写法
上面代码里的build_system_prompt()是核心,我把它单独拎出来讲。完整的 prompt 长这样:
def build_system_prompt(): tool_desc = "\n".join([ f"- {name}({', '.join(info['params'].keys())}): {info['description']}" for name, info in TOOLS.items() ]) return f"""你是一个能够使用工具的智能助手。你可以通过思考和调用工具来解决问题。 可用工具: {tool_desc} 你必须严格按照以下格式输出,每次只输出一个步骤: 格式一(需要调用工具时): Thought: 你的思考过程 Action: 工具名称 Action Input: 工具参数的 JSON 格式二(已经可以回答时): Thought: 你的思考过程 Final Answer: 你的最终答案 规则: 1. 每次只能输出一个 Thought 和一个 Action,或一个 Thought 和一个 Final Answer。 2. Action 必须是上面列出的工具名称之一。 3. Action Input 必须是合法的 JSON。 4. 当你已经获得足够信息时,必须输出 Final Answer 结束任务。 5. 不要编造工具的执行结果,一切以 Observation 为准。 示例: 用户:北京今天天气怎么样? Thought: 我需要查询北京的天气,应该使用 get_weather 工具。 Action: get_weather Action Input: {{"city": "北京"}} Observation: 北京今天晴,5-15度。 Thought: 我已经获得了天气信息,可以回答了。 Final Answer: 北京今天晴天,气温 5 到 15 度。 """这个 prompt 里,我特意加了 few-shot 示例。实测下来,带示例的 prompt 比不带示例的格式正确率高出一大截。模型是模仿高手,你给它一个模板,它就照着填。
还有个小细节:Action Input的示例里我用了{{"city": "北京"}},双大括号是因为这是 f-string,需要转义。这个坑我第一次写的时候踩了,忘了转义,结果 Python 直接报 KeyError。
4.4 一个完整工具的实现示例
光说不练假把式,给个计算器工具的完整实现:
import ast import operator def calc_impl(expression): """安全地计算数学表达式,只允许基本运算""" allowed_ops = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.Pow: operator.pow, } def eval_node(node): if isinstance(node, ast.Num): return node.n elif isinstance(node, ast.BinOp): op = allowed_ops.get(type(node.op)) if op is None: raise ValueError(f"不支持的运算符:{type(node.op).__name__}") return op(eval_node(node.left), eval_node(node.right)) else: raise ValueError(f"不支持的表达式节点:{type(node).__name__}") tree = ast.parse(expression, mode="eval") return eval_node(tree.body)这里我没有用 eval,而是用 ast 解析后手动求值。原因很简单:eval 会执行任意代码,如果模型被诱导输出了恶意表达式,eval 就是个安全漏洞。用 ast 白名单的方式,只允许加减乘除和幂运算,安全得多。这就是 Agent 安全里最基础的一条:永远不要信任 LLM 的输出,所有执行都要做沙箱和白名单。
注意:工具实现里凡是涉及执行、文件操作、网络请求的,都要做权限控制和输入校验。Agent 的自主性越强,安全边界就越要收紧。
5. 常见问题与排查技巧实录
5.1 模型不调工具,直接编答案
这是最常见的问题。模型明明有工具可用,却直接输出 Final Answer,答案还是编的。
排查思路:先看 prompt 里有没有明确"必须优先使用工具获取信息"。很多时候是 prompt 没强调,模型就偷懒了。其次看工具描述是不是不够清楚,模型没意识到这个工具能解决当前问题。
解决方法:在 prompt 里加一条规则——"如果问题涉及实时信息、计算或你不确定的事实,必须先调用工具,不得直接回答"。我加了这条之后,编答案的情况少了八成。
5.2 解析器频繁匹配失败
模型输出格式不稳定,正则匹配不上。
排查思路:把模型每次的原始输出打出来看,找出它偏离格式的具体模式。常见的有:多输出了一句解释、Action Input 不是 JSON、Thought 和 Action 写在同一行。
解决方法:一是 prompt 里加强格式约束,用"必须"和示例;二是解析器做容错,比如 Action Input 解析失败时降级成字符串;三是格式错误时给模型明确的纠正提示,让它重试。我一般会给两次重试机会,两次还错就放弃。
5.3 循环停不下来
模型一直调工具,不输出 Final Answer。
排查思路:看是不是工具返回的结果让模型觉得"信息还不够"。有时候是工具返回了空结果,模型就一直重试。
解决方法:一是设 max_steps 硬上限;二是在 prompt 里加"如果工具多次返回相同结果,说明该信息无法获取,请基于现有信息回答";三是工具返回空结果时,Observation 里明确写"未找到相关信息",而不是返回空字符串。
5.4 上下文超长导致响应变慢或报错
多轮之后 token 爆了。
排查思路:统计每轮的 token 消耗,看是哪一步开始膨胀的。通常是工具返回了超长结果(比如搜索返回了一大段网页)。
解决方法:一是工具返回结果做截断,比如搜索结果只取前 500 字;二是做上下文裁剪,只保留最近 N 轮;三是把早期记录压缩成摘要。
下面这张表是我整理的常见问题速查表,方便你对照排查:
| 问题现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 不调工具直接编答案 | prompt 未强调优先用工具 | 检查 prompt 规则 | 加"必须先调工具"规则 |
| 解析频繁失败 | 输出格式不稳定 | 打印原始输出找规律 | 强化 prompt + 解析容错 |
| 循环停不下来 | 无终止条件或工具返回空 | 看工具返回和步数 | 设 max_steps + 空结果提示 |
| 上下文超长 | 工具返回过长或轮次过多 | 统计 token 消耗 | 截断 + 裁剪 + 摘要 |
| 工具选错 | 工具描述不清晰 | 检查工具描述 | 描述加具体例子 |
| 参数格式错 | 未标参数类型 | 看模型传的参数 | 描述里明确类型 |
5.5 几个我踩过的坑和独家技巧
坑一:temperature 设太高。我一开始用默认的 0.7,结果模型输出格式飘忽不定,解析器天天报错。改成 0.0 之后稳定多了。Agent 场景,稳定压倒一切。
坑二:工具描述写得太抽象。我写过一个"处理文本"的工具,描述就四个字,结果模型从来不用它,因为它不知道这工具能干嘛。后来改成"统计文本字数、提取关键词、转换大小写",使用率立刻上来了。
坑三:忘了处理工具返回非字符串的情况。有次工具返回了个字典,我直接拼进 Observation,结果模型看到的是 Python 字典的 repr,一脸懵。后来统一在工具层做序列化,返回字符串。
技巧一:给 Observation 加前缀。我在 Observation 前面统一加Observation:标记,让模型清楚知道这是工具返回的,不是用户说的。这个标记和 prompt 里的格式对应,模型理解起来更顺。
技巧二:记录每一步的完整 trace。每次循环的 Thought、Action、Observation 都存下来,出问题的时候回放一遍,一眼就能看出是哪一步跑偏了。这个 trace 对调试的价值,怎么强调都不过分。
技巧三:给工具加超时。工具执行可能卡住(比如网络请求),一定要加超时,超时后返回"工具执行超时"作为 Observation,让模型决定下一步。没有超时,整个 Agent 可能就挂在那了。
6. 从最小 Agent 到可用系统的扩展方向
跑通最小版本之后,你会发现它离"能用"还有距离。我按优先级列几个扩展方向,都是我自己实际加过的。
第一,加记忆持久化。最小版本的记忆只在单次任务内有效,任务结束就没了。要支持多轮对话,得把历史存到外部(文件或数据库),下次任务开始时加载。这里要注意区分"短期记忆"(当前任务的中间步骤)和"长期记忆"(跨任务的事实),两者的管理策略不一样。
第二,加工具的动态注册。最小版本的工具是写死的字典。实际项目里,工具应该能动态注册和发现,甚至支持从配置加载。这样加新工具不用改主循环代码。
第三,加多 Agent 协作。单个 Agent 能力有限,复杂任务可以拆给多个专职 Agent,一个负责规划,一个负责执行,一个负责校验。这就是 Agent 编排的范畴了。但我的建议是,先把单 Agent 跑稳,再考虑多 Agent。单 Agent 都没搞明白就上多 Agent,只会让问题更复杂。
第四,加可观测性。生产环境的 Agent 必须有日志、指标、trace。每次调用的输入输出、耗时、token 消耗、工具调用成功率,都要能监控。不然出了问题你连从哪查都不知道。
第五,加安全护栏。前面提到的工具沙箱、输入校验、权限控制,都是必须的。还有一条容易被忽略:对 LLM 的输出做敏感内容过滤。Agent 会调用工具、会返回内容,这些内容在展示给用户前要过一遍过滤。
关于 Agent 安全,我多说一句。现在有个热词叫 agentpoison,讲的是通过污染 Agent 的记忆或知识库来攻击它。这个思路值得警惕:如果你的 Agent 有长期记忆,而记忆可以被外部输入写入,那攻击者就能通过注入恶意记忆来操控 Agent 的行为。防御方法是在写入记忆前做校验和清洗,不要什么都往记忆里塞。
最后分享一个我个人的判断:Agent 开发的难点从来不在"调通",而在"调稳"。让一个 Agent 跑通一次很容易,让它在一百次任务里稳定跑对九十次,才是真功夫。而稳定的关键,就在那些不起眼的地方——prompt 里的一句约束、解析器里的一个容错分支、工具返回前的一次截断。这些细节,框架不会替你思考,只有自己写一遍,才会真正长在手上。
我现在的习惯是,每接一个新的 Agent 需求,先用最小实现跑一个 demo,把 ReAct 循环、工具、解析器都手写一遍,确认逻辑通了,再决定要不要上框架提效。这个"先手写后框架"的流程,帮我避开了很多"框架能跑但不知道为什么能跑"的坑。如果你也在学 Agent 开发,不妨也这么试试,从 pi-agent 这样的小实现参照起,把每一行都搞明白,比调一百次库都值。