☰
从零实现迷你Agent:剥开pi-agent核心循环与工具设计
2026/10/22 5:10:56 网站建设 项目流程

做 Agent 开发这段时间,我把手边一个不算知名的小开源项目(姑且叫它 pi-agent)翻来覆去读了很多遍,然后照着它的思路,自己从零写了一个能对话、能拆解任务、能调用工具的迷你 Agent。整个过程做完,最大的感受是:Agent 的核心其实不在模型的“智力”高低,而在于调度循环和工具设计。这篇文章就是把剥开 pi-agent 后看到的、以及我照抄思路落地时的收获,完整摊开来讲。

内容不依赖重型框架,核心代码一个 Python 文件就能装下。适合两种人看:一种是已经会用大模型 API 做单轮问答,但对 Agent 内部到底怎么“转圈”还很模糊的开发者;另一种是准备自己写 Agent,却不知道从哪个模块下手的编程老手。读完以后,你能拿走的不是一堆概念,而是一个清晰的主循环骨架,再加上工具注册、记忆管理、容错处理这三块可以直接复用的代码模板。

1. 剥开前先对齐概念:Agent 到底是个什么形态的代码?

1.1 “闭环”才是 Agent 的分界线

很多人以为 Agent 就是“调用大模型 + 套个提示词”。这其实差得很远。一个纯问答接口,输入一句话返回一句话,流程走到一次就结束了,这叫“单轮推理”;就算你把它包在代码里循环十遍,只要每次调用之间没有状态、没有工具反馈、没有下一步决策依据,它依然不是 Agent。

真正意义上的 Agent,是一个在环境里反复“感知、决策、执行、观察”的闭环。这个闭环至少要完整跑好几圈:看到用户输入,决定要做什么,调用工具,拿到结果,根据结果调整计划,再继续走下一步,直到得出答案。模型只是在每一轮里扮演“做决策的那个脑细胞”,真正完成工作的,是外面那一层套着它的循环逻辑。

我一开始犯的错误就是把模型调用当成了 Agent 的全部。后来对照 pi-agent 的源码才发现,它绝大部分精力都花在“怎么把一轮一轮的决策串起来”上,模型调用只是其中很小的一步。

1.2 Agent 的三块拼图:规划、行动、观察

如果把一个 Agent 的循环拆开,其实就三块拼图:

  • 规划(Plan):根据用户目标和当前状态,决定下一步做什么。这块通常由模型完成,它读入历史消息、工具说明和当前观察结果,输出一个“行动倾向”。
  • 行动(Act):把模型的决策变成真实世界的副作用。比如查数据库、发请求、写文件、执行计算。这一层不该让模型直接“嘴炮”完成,必须由代码来控制。
  • 观察(Observe):把行动的返回结果重新喂给模型,让它知道“刚才那步到底成了没有”。这是闭环能够持续的关键,少了这个环节,Agent 就变成瞎子开车。

举一个最容易理解的类比:Agent 就像一个坐在工位上的员工。模型是这个员工的“大脑”,工具是“手”,外部系统是“环境”。大脑想了半天说“我要打个电话”,但有没有手去拨号、电话打完对方说了什么,大脑是不知道的,它只能靠听筒那边的反馈来修改下一步。所以观察环节不是可有可无,它是 Agent 持续工作的氧气。

1.3 一个容易被忽略的结论

把 Agent 拆到这个粒度以后,会发现一个很反直觉的结论:模型的能力边界决定了 Agent 的上限,但循环和工具设计决定了 Agent 的生死。

很多 Agent 项目跑不起来,不是因为模型不够聪明,而是因为循环写得稀烂:工具调用之后拿不到反馈、上下文越来越混乱、状态不知道存在哪里、跑几轮就超时崩溃。这些问题没有一个是模型层面能解决的,全部属于工程问题。

所以后面所有章节,我都会围着这个闭环展开:先把 pi-agent 的骨架拆给大家看,再自己动手复刻一个能跑的迷你版。

2. 为什么可以把 pi-agent 当参照:拆解一个成熟小项目的骨架

2.1 从入口一路看进去:五层模块

pi-agent 这个项目不算复杂,代码量不大,但恰好该有的零件都有。我通读源码时,发现它的结构大概可以分成五层:

  • 入口层(Entry):负责接收用户输入,启动一次 Agent 运行。这一段不需要有业务逻辑,只负责把参数解析好、把会话状态准备好。
  • 会话层(Session):保存当前对话上下文,包括历史消息、本轮状态、变量记录。Agent 每一轮决策都要读写这个 Session。
  • 规划层(Planner):这是 Agent 的“大脑”。它调用大模型,把 Session 里的信息加上工具列表,翻译成下一步行动。
  • 工具层(Toolbox):维护一份工具清单,每个工具都有名字、说明、参数格式和实际执行函数。规划层说要调用哪个工具,真正动手执行的是这一层。
  • 执行循环(Runner):上面所有层都要被这个 Runner 串起来。Runner 循环做三件事:让规划层出决策,如果决策是调用工具就去行动,然后把观察结果写回 Session,再进入下一轮。

这种分层的想法未必是 pi-agent 原创,但它做了一个很正确的决定:没有把逻辑都糊在一起。每个模块负责的职责非常清晰,我可以直接照搬这种分层思想,而不必照抄它的代码细节。

2.2 从 pi-agent 里学到的三个设计习惯

除了分层,我还在源码里看到了三个非常值得记下来的设计习惯:

第一,工具描述写得很啰嗦。每个工具的 description 字段都不是一句话带过,而是写清楚“这个工具适合什么场景、能做什么、不能做什么”。一开始我觉得多余,后来才发现模型能不能正确地选择工具,很大程度上就靠这段描述。把描述写瘦了,模型就会乱用。

第二,Session 和 Agent 本体分离。pi-agent 没有把消息历史堆在 Agent 对象里,而是单独抽了一个 Session 对象。这样做好处太多:并发请求时不会相互污染状态,调试时可以单独把 Session dump 出来看,做多轮对话天然支持。后来我自己写的时候也严格遵守这个设计,再没出现过“两个用户串了上下文”这种事故。

第三,用 JSON 结构化返回工具执行结果。每个工具执行完不是返回一段自然语言,而是返回一个 JSON 对象,里面至少包含success和data两个字段。这对模型非常友好,它不需要从一大段话里找重点,直接读 JSON 就明白“工具执行成功,数据是哪些”。而且代码里做判断也更方便,不用做文本匹配。

2.3 哪些该抄,哪些必须自己改

参照一个项目不是照着复制粘贴。pi-agent 的设计毕竟是为它自己的业务场景服务的,我抄的是它的骨架和习惯,但有几处必须自己改:

  • 工具集合完全不同。pi-agent 自带的是跟它业务相关的工具,我要做的迷你 Agent 得注册自己的工具。
  • 上下文管理策略要简化。pi-agent 里做了不少记忆优化,我第一版直接砍掉,只留一个历史列表,跑通了再迭代。
  • 模型调用方式要适配我要用的接口。不同模型服务商的 API 会有些差异,我把调用层封装成一个函数,后面换模型只改一个地方。

这个“先抄骨架、再改肌肉”的思路,我自己觉得是学习一个开源项目最高效的路径。强烈建议你们也别一上来就指望能用现成代码,先把它的结构和设计习惯吃透,再动手改造。

3. 动手前先把目标和边界定死

3.1 目标:做一个“任务拆解小管家”

有了骨架思路,接下来不能立刻写代码,先得给项目画一个边界。

我给这个迷你 Agent 定的目标是:做一个“任务拆解小管家”,它能接收用户的一个模糊目标,然后把它拆成清晰步骤,并实际调用工具完成其中一部分简单动作。

核心场景是这么设计的:用户说“我最近太忙了,帮我规划一下今天剩余时间”,Agent 先看当前的待办列表(工具一),再在后面追加一些新任务(工具二),最后用计算器算一下每件事预估需要多少分钟(工具三),把结果整理成一段清单回复。

这个场景覆盖了 Agent 最重要的三种能力:拆解目标、读取数据、执行动作。麻雀虽小,五脏俱全,用来验证循环逻辑完全够用。

3.2 我刻意砍掉的功能清单

定边界最忌讳贪多。我对照 pi-agent 的功能清单,把自己第一版不需要的东西全砍了:

  • 不接长期向量数据库。记忆只用简单的 JSON 文件落盘,不上向量检索。
  • 不做多 Agent 协作。只有一个 Agent 自己闭环跑完,不做群体智能那一套。
  • 不做 Web 界面。先用命令行交互,把核心逻辑跑稳。
  • 不接复杂知识库。工具返回什么就是什么,不做 RAG 增强。
  • 不做任务持久化和恢复。进程一关,任务状态直接清空。

砍掉这些以后,代码复杂度直接降了一个量级。事实证明这个决定非常明智,我后面排障时,绝大多数问题都能定位到主循环本身,而不是被各种外围功能干扰。

3.3 技术选型就一句话

技术选型一句话就能说清楚:Python + 一个支持“函数调用/工具调用”能力的大模型接口,封装成一个统一调用层。

为什么要求模型必须支持函数调用?因为在闭环里,我需要的不是模型输出一串文字说“我要调用工具”,而是模型在结构化返回里明确给出“我要调用哪个工具、传什么参数”。这也是 pi-agent 这类项目默认采用的方式,比起让模型输出自然语言再解析,函数调用的可靠性高得多。

选 Python 是因为生态成熟,处理 JSON、文件、网络请求都很顺手。如果你对别的语言更熟,也没关系,核心逻辑跟语言无关,只是我下面贴的代码用 Python 写。

4. 把“大脑”和“手脚”接起来:核心循环实现

4.1 先定义工具:JSON Schema + 执行函数

在写循环之前,得先把工具注册机制搭好。每个工具我设计成一个字典加一个函数:

import json import ast import operator # 工具:安全计算器 def calculator(expression: str): """只允许做基本四则运算的安全 eval""" tree = ast.parse(expression, mode="eval") allowed_types = ( ast.Expression, ast.Constant, ast.BinOp, ast.UnaryOp, ast.Add, ast.Sub, ast.Mult, ast.Div, ) for node in ast.walk(tree): if not isinstance(node, allowed_types): return {"success": False, "error": "表达式中包含不允许的运算"} # 手动求值,避免直接调用 eval 带来的风险 def _eval(node): if isinstance(node, ast.Constant): return node.n if isinstance(node, ast.BinOp): op = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, }.get(type(node.op)) return op(_eval(node.left), _eval(node.right)) if isinstance(node, ast.UnaryOp): if isinstance(node.op, ast.USub): return -_eval(node.operand) if isinstance(node.op, ast.UAdd): return _eval(node.operand) raise ValueError("不支持的运算节点") try: value = _eval(tree.body) return {"success": True, "data": value} except Exception as e: return {"success": False, "error": str(e)} TOOLS = { "calculator": { "type": "function", "function": { "name": "calculator", "description": "计算一个数学表达式的值。适合进行四则运算,不支持变量和函数调用。", "parameters": { "type": "object", "properties": { "expression": { "type": "string", "description": "需要计算的数学表达式,例如 '(12+34)*5'。" } }, "required": ["expression"] } }, "handler": calculator, } }

这个工具定义方式是从 pi-agent 学来的:一个工具 = 一份 JSON Schema + 一个真正的 Python 函数。模型只负责看 Schema 并决定调用哪个工具、传入什么参数,真正的执行永远走本地函数。这句话值得划重点:Agent 里所有实际操作,都必须由代码执行,不能让模型自己去“假装执行”。

后面要加第二个工具,比如“读取备忘录”,就照这个格式再写一个函数、再注册一份 Schema。工具多了以后可以统一放在一个列表里,循环里只需要遍历这个列表。

4.2 消息结构:模型怎么告诉我们“我要调用工具”

接下来这一块是很多初写 Agent 的人最容易踩坑的地方:消息结构。

大多数支持函数调用的模型服务,在对话格式上是一致的:每一条role是"user"或"assistant"的消息后面,可能跟一条"tool"消息来表示工具返回。当模型决定调用工具时,它返回的 assistant 消息里会带一个tool_calls字段,里面包含工具名和参数。我们必须把这条 assistant 消息原样保存进历史,然后把工具执行结果以role="tool"的消息追加回去。

{"role": "assistant", "content": null, "tool_calls": [{"id": "call_xxx", "function": {"name": "calculator", "arguments": "{\"expression\": \"(12+34)*5\"}"}}]} {"role": "tool", "tool_call_id": "call_xxx", "content": "{\"success\": true, \"data\": 230.0}"}

注意两点。第一,content字段在模型返回 tool_calls 时可能为空,别急着丢弃这条消息。第二,arguments有时候是 JSON 字符串,有时候已经是字典,稳妥起见统一做一次json.loads处理。

4.3 循环代码:一段能跑的 MiniAgent

工具定义好、消息结构搞清楚,剩下的主循环代码其实短得惊人。我第一版写出来只有四十多行,参考的是 pi-agent Runner 的思路:

class MiniAgent: def __init__(self, llm_client, tools, max_iters=8): self.llm = llm_client self.tools = {t["function"]["name"]: t for t in tools} self.max_iters = max_iters def run(self, user_input, session=None): if session is None: session = [] session.append({"role": "user", "content": user_input}) for step in range(self.max_iters): # 1. 让模型基于历史 + 工具清单做决策 response = self.llm.chat( messages=session, tools=[t for t in self.tools.values()], ) # 2. 把 assistant 回复补进历史,无论它是否调用工具 session.append({ "role": "assistant", "content": response.content, "tool_calls": response.tool_calls or [], }) # 3. 没有工具调用,说明模型认为可以收尾了 if not response.tool_calls: return response.content, session # 4. 逐个执行工具,并把结果写回会话 for call in response.tool_calls: tool_name = call.function.name tool = self.tools.get(tool_name) if not tool: result = {"success": False, "error": f"工具 {tool_name} 不存在"} else: args = call.function.arguments if isinstance(args, str): args = json.loads(args) try: result = tool["handler"](**args) except Exception as e: result = {"success": False, "error": str(e)} session.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps(result, ensure_ascii=False), }) return "迭代次数用尽,未能完成目标", session

这段代码就是整个 Agent 的心脏。第五步的for step in range(self.max_iters)是安全阀,没有它,模型一旦想不开就能无限循环。max_iters我通常设置成 8,对绝大部分小型任务足够了。

写到这里你可能已经发现,Agent 的“智能”含量其实不高,它只是把一个决策循环稳定地跑起来了。但这恰恰是最值得先掌握的部分——先把循环跑通,后面加记忆、加工具、加护栏,都是在给这个循环添加外挂。

5. 给 Agent 装上记忆:会话上下文管理

5.1 短期记忆:会话消息队列

跑通循环以后,第二个必须解决的问题是:会话历史不能无限膨胀。每轮推理,大模型都要把历史消息整个吃进去一次。如果对话超过了十轮、每轮工具返回又很长,很快就把上下文窗口塞满了。

最简单有效的方式是给 Session 加一个长度上限。比如最多保留最近 20 条消息,超过之后把最早的消息丢掉。这个思路很像给队列做滑动窗口,实现起来也就几行:

MAX_HISTORY = 20 def trim_session(session): if len(session) > MAX_HISTORY: return session[-MAX_HISTORY:] return session

不过后来我发现,直接暴力截断会带来一个新问题:如果早期消息里包含了用户最初的目标,截断了以后模型可能会忘记自己到底在干嘛。所以开发版里我不会截到太短,至少保留第一条 user 消息,把它固定在最前面。

5.2 长期记忆:摘要 + 落盘

迷你 Agent 不需要向量库,但可以做一个更轻的长期记忆:每轮对话结束后,调用模型把整段会话压缩成一段摘要,存进本地 JSON 文件。下次启动时,把摘要作为“历史背景”放在系统提示词里。

这段摘要策略,我是从 pi-agent 里类似机制简化来的。核心代码也很朴实:

def summarize(session, llm_client): content = "\n".join( f"{m.get('role')}: {m.get('content', '')}" for m in session if m.get("content") ) prompt = f"请用 3-5 句话概括以下对话的任务、关键信息和当前结论:\n{content}" resp = llm_client.chat([{"role": "user", "content": prompt}]) return resp.content

长期记忆的定位是“粗粒度背景”,不是闹钟式精确记录。它帮模型记住“用户在帮公司做一个营销方案”这类总目标,而不是“用户在第 5 轮提到过一个数字”。精确的动作和结果,应该由工具自己的存储负责。这样分工才不别扭。

5.3 记忆设计的死线:别为了花哨毁掉可用性

很多入门项目在记忆上花的时间比主循环还多,有点本末倒置。我的经验是:第一版只做窗口截断 + 会话内状态,能跑就行;第二版加上摘要和落盘;第三版再考虑知识库。为什么这么排序?因为记忆效果很难一眼看出好坏,如果连基础循环都不稳定,记忆层再花哨也是空中楼阁。

另外还要留意一个反直觉的现象:模型被塞进太多冗余上下文之后,反而会表现变差。它可能被历史消息里的错误信息带偏,或者被很长很长的工具返回干扰。适当地让上下文“空一点”,比一味追求“记得全”更重要。

6. 让 Agent 耐摔:容错、超时与隔离

6.1 工具执行要做的三件防护

一个真正好用的 Agent,不是那种“成功跑通一次演示”的工具,而是那种别人拿过去喂了 100 个刁钻输入还不崩溃的工程。想让 Agent 耐摔,工具层至少要加三道防护:

第一道:工具必须白盒包裹异常。任何工具执行函数里抛出的异常,都不能让主循环崩溃。我在循环里用 try/except 把工具调用接住了,而且把报错信息序列化成 JSON 丢回给模型。这么做有个意想不到的好处:模型看到错误信息后,常常会自己换一种方式重试,比如发现参数格式不对,自动改成正确格式再调一次。

第二道:给所有外部工具设置超时。有些工具要发网络请求,要是对端一直不响应,Agent 会被卡死。我写了一个统一的timeout_wrapper,任何注册到工具列表里的 handler 都先包一层超时控制。内部实现用concurrent.futures.ThreadPoolExecutor,超时时间默认 10 秒。

第三道:对返回值做大小上限限制。工具返回的内容如果特别大(比如查询了一个几千行的列表),会把上下文窗口直接打爆。我在工具层规定:返回给模型的 content 最多 4000 个字符,超出部分强制截断并追加一条“结果过长已截断”的说明。

6.2 对大模型调用的容错

模型接口也不是永远稳定。我踩过好几次模型服务超时、限流、返回畸形 JSON 的情况。所以调用层也包了一把伞:

  • 对模型请求设置超时,默认 30 秒。
  • 出现连接类错误时,指数退避重试,最多 3 次。
  • 如果模型返回的 tool_calls 参数解析失败,直接把解析异常返回给模型,让它重新整理参数格式。

这个最后一条尤其好用。有一次一个模型把参数值里混进了多余的空行,直接json.loads报错。我一开始想写成代码自动清洗,后来发现把报错信息丢回给模型让它自己修正,效果反而更稳。这种做法其实就是前面提到的“观察”机制的具体应用:把错误当成一次工具返回,让模型在下一次决策里修正自己。

6.3 调试手段:给 Agent 装上“记录仪”

Agent 运行过程比较黑盒,不加日志,出了问题什么线索都没有。我照着 pi-agent 的日志风格,给每次迭代打了三种日志:

[iter 1][plan] 模型决定调用 calculator,参数 {"expression": "(12+34)*5"} [iter 1][action] 工具执行耗时 0.02s,成功 [iter 1][observe] 返回数据 230.0

这些日志在开发期是救命稻草。查看顺序也很明确:先看模型决策对不对,再看工具执行结果对不对,最后看下一轮模型有没有根据结果调整。只要把这三步盯住,绝大多数循环问题都能肉眼定位。

7. 完整实操记录:从一个空目录到能跑的小 Agent

7.1 第一步:搭最小的对话循环

我不建议直接上手完整版。最稳的路线是分阶段加量,每阶段都跑通验证。

第一阶段只写一个“无工具版对话循环”,核心就是一个while,用户输入丢给模型,模型返回纯文本,打印出来。代码量不到 20 行。这一步的目的是先把模型 API 的调用、消息格式、流式输出这些基础环境跑通。

跑通以后,快速做一些冒烟测试:连续对话几轮,确认模型能记住上下文,确认消息历史在正常累加。如果这个阶段出问题,大概率是 API 配置问题,和 Agent 逻辑无关。

7.2 第二步:注册第一批工具

第二阶段把工具机制加进来。我建议先只接一个工具:安全计算器。为什么选计算器?因为它不依赖外部网络,结果可以手动验证,出错原因也一目了然。

测试时给一句话:“请帮我算一下 35 乘以 12 再加 78 等于多少”。模型应该返回一个 tool_calls,主循环执行计算器,再把结果返回,模型最后总结成自然语言。这一步跑通,就说明 Agent 闭环最关键的一环通了。

接着再加第二个工具,比如简单的待办列表工具:

TODO_NOTES = [] def add_todo(content: str): TODO_NOTES.append({"content": content, "done": False}) return {"success": True, "data": {"total": len(TODO_NOTES)}} def list_todos(): return {"success": True, "data": TODO_NOTES}

把这两个工具都注册进 TOOLS 字典以后,测试一个组合场景:“我今天的备选任务有写周报、回复邮件、整理工位,帮我加到待办列表,然后统计一下现在一共有几项。” 这时候 Agent 应该会先调用list_todos查看当前列表,再调用add_todo逐条添加。如果它一次调多个工具,循环代码里那层for call in response.tool_calls就会发挥作用。

7.3 第三步:挂上记忆与工程配置

第三步开始打磨可复用性。我把 Session 的裁剪逻辑抽成独立函数,把记忆摘要功能加进去,再写一个 JSON 配置文件,里面放模型名称、API 地址、温度、最大迭代轮数等参数。

配置文件长这样:

model_name: "your-model" api_base: "http://localhost:8000/v1" max_iters: 8 max_history: 20 tool_timeout: 10 temperature: 0.3

注意temperature我习惯调低到 0.3。Agent 规划比创意写作更强调确定性,温度太高模型容易在循环里“发挥”,搞出各种不稳定的决策。

7.4 运行效果示例与继续迭代方向

完整版跑起来以后,一次典型交互大概是这种感觉:

你:帮我把今天剩下的任务理一下,做的时候想先处理重要紧急的。 Agent: 我先看看当前的待办列表里有什么。 [tool] list_todos 参数 {} [tool] 返回 [{"content": "写周报", "done": false}, {"content": "回复邮件", "done": false}] Agent: 当前列表有两项。我先按“重要紧急”的原则排个序: 写周报(重要且今日要交)-> 回复邮件(日常事务)-> [tool] calculator 参数 {"expression": "45+20"} [tool] 返回 65 Agent: 两项预计共需大约 65 分钟,建议先集中精力写周报,再统一处理邮件。

到这里,一个参照 pi-agent 思路实现的小 Agent 已经完成闭环。后面继续迭代可以加的方向很多:接入更多外部工具、做任务持久化、换更强的模型、加多轮自主反思。但无论加什么,都是在这个已经稳定的骨架上贴肉,而不是推倒重来。

8. 常见问题与排查技巧实录

8.1 工具明明执行失败,模型却“假装”成功

这是我见过最多的问题,也是我自己早期经常遇到的。现象是:工具返回了{"success": false, "error": "..."},但模型依然在回答里说“已经帮你完成了”。

根本原因在于:模型在对话里表达“我做了”,不等于真的做了。要治这个问题,得在系统提示词里反复强调规则:“只有 tool_calls 里列出的操作才是真实操作;所有文字描述都不能视为执行。”另外在工具返回里,也要给错误信息加明显标记,比如"success": false时,模型读到这个字段就应该自动转入“告知用户失败”的状态,而不是继续嘴硬。

8.2 Agent 陷入循环,反复调用同一个工具无法停止

经典现象是:模型查了一次待办,发现任务没完成,又开始规划,然后又去查,每次拿到的结果一模一样,但就是不收尾。这可能是因为观察环节没有刺激到模型“目标已经达成”的判定。

我的排查套路有三步:

  • 第一,检查max_iters是否太小,模型还在做事就被强行掐断,有时会表现为来回横跳。
  • 第二,检查工具返回的信息是否足够“决策友好”。如果返回里没有“当前还剩几项、每项状态是什么”这类结构化信息,模型很难判断目标是否完成。
  • 第三,给系统提示词加一句终止条件:“当且仅当所有请求的动作都已执行并确认完成,或用户目标已达成时,才停止工具调用并输出最终回答。”

8.3 上下文迅速膨胀,第二三轮就开始报错

通常不是模型问题,而是工具返回内容太长。比如工具查了一个全量数据表,一次返回几百行文本,那上下文一次就被吃掉了大半。解决办法是在工具内部提前做聚合,只返回统计信息和前几条样例,完整数据放到文件里由 Agent 自己按需读取。

还有一个经常被忽略的点:tool消息里那串 JSON 可以压缩。我统一把返回的 JSON 里的空格去掉,能省不少 token。虽然听起来很抠,但在长会话里这种细节累积起来很可观。

8.4 换个模型就失灵,原先好好的工具调用全乱了

不同模型对函数调用的支持程度差异很大。有的模型对参数格式要求严格,有的喜欢把参数里所有值当成字符串,还有的模型根本就不支持tool_calls这种结构化返回。

这个问题我建议用“环境隔离 + 兼容层”来解决:把模型调用封装成一个函数,内部做参数格式归一化。比如有的接口要求arguments是 JSON 字符串,有的直接收字典,封装层统一转成字典再传给工具。换模型时只改封装层,主循环一动不动。

8.5 system prompt 写了一堆,模型反而更不听话

系统提示词太长,有时候会把模型搞懵。尤其是把各种边界规则、对话风格、工具说明全塞进去之后,模型可能忽略最核心的指令。

我的经验是提示词分三层写:第一层是角色和总目标,第二层是运行规则(什么时候调用工具、什么时候停止),第三层放少量对话风格要求。工具说明不要复制到提示词里,它会由函数调用机制自动拼给模型,重复出现只会浪费 token。

8.6 多轮对话时 Session 状态串了

这是我自己踩过的大坑之一。一开始把 Session 信息存在 Agent 对象的成员变量里,只要一个脚本里同时跑两个任务,就互相覆盖。后来照着 pi-agent 的 Session 分离思想,把历史列表改成调用方传入的独立对象,情况才彻底好转。

写代码时记住一个铁律:Agent 实例应该是无状态的,所有状态都放在传入的 session 里。这样写出来的 Agent 天然支持并发、支持持久化、支持测试。

写在最后的一点体会

把 pi-agent 剥开、再自己从零写一个小 Agent,这个过程最大的收获不是代码量,而是建立了一种“拆解 Agent”的直觉。现在看到任何一个 Agent 项目,我都不会先被它的宣传话术带跑,而是下意识去分辨:它的主循环在哪,工具注册怎么做,记忆放哪里,终止条件是什么,观察反馈是否完整。这五个问题一问完,项目的水准大概就有数了。

如果你想自己动手,我给一条最实际的建议:先别碰任何重型框架,就用原生 Python 写一个 50 行的主循环,接两个工具跑起来。等你真正体会到“模型说一句话,代码动一次手,结果再喂回模型”这个节奏以后,再考虑要不要引入那些漂亮但厚重的上层建筑。到那个时候,你会发现那些框架里的概念,你已经全都能看懂了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询