资讯动态

100行Python从零搭建最小智能体:工具调用与上下文管理

发布时间:2026/10/24 6:41:51 来源:尧图企业网站定制
见过不少刚碰智能体的同学第一反应是先去装一个编排框架再配个向量库顺手把工具市场、多智能体、记忆模块全塞进去结果一个帮我算一下这个文件里数据的平均值的请求都跑不通报错信息还藏在第五层回调里。真正跑通那天你会发现一个能干活的最小智能体Mini AI Agent骨架其实短得可怜一个循环、一张工具表、一段系统提示词加起来一百行上下。这篇文章就聊这件事怎么用 100 行代码从零搭一个能真实调用工具、能自己判断何时收手的智能体每一块代码为什么这么写以及把它跑起来之后必然会撞上的那些坑。适合两类人一类是刚学完接口调用、想搞明白 Agent 到底智能在哪的新手另一类是已经在用框架、但一直不清楚框架替你做了什么、出了问题只能靠猜的开发者。代码用 Python不依赖任何 Agent 专用库模型接口走通用的对话补全协议换成你能调通的任何一家都行。1. 把智能体这个词拆开100 行里到底该装什么1.1 剥掉框架之后剩下的其实只有四件东西我习惯用一个类比智能体就像一个被关在小房间里、只能通过纸条跟外界沟通的实习生。他看不见屏幕摸不到键盘唯一的动作是写一张纸条递出去然后等外面的人把结果写回来。这个大语言模型本身没有手脚它唯一的输出能力是生成文本。所谓Agent是我们围绕这个只会说话的模型额外搭了一套管道让它说的话可以被解析成动作动作被执行后结果能回到它的上下文里然后它再说下一句话。这套管道的学名叫思考-行动-观察循环工程上就是多层嵌套的while。拆到最底层100 行里必须装下的只有四样东西模型调用层把对话历史和工具清单发出去、把回复拿回来、工具注册表告诉模型有哪些动作可以做、参数长什么样以及真正执行这些动作的函数、主循环判断这一轮该结束还是该继续把执行结果塞回上下文、上下文控制器防止对话历史无限膨胀把模型撑爆。剩下的什么记忆持久化、多智能体协作、任务规划、反思机制全都是在这四件东西之上叠的锦上添花不是必需品。部件职责最少需要多少行省掉它的后果模型调用层收发一次对话请求10-15 行没有它就没有大脑工具注册表声明工具 执行工具25-35 行模型只能空谈无法接触真实世界主循环判定继续 / 终止回填结果30-40 行只能单轮问答不是智能体上下文控制截断、裁剪、压缩历史10-20 行长任务跑到中途直接超长报错这张表我想强调一个反直觉的点主循环占的行数最多但它几乎不含任何智能。它的全部工作就是搬运消息、判断分支。真正的智能全部来自模型本身你的代码只是个勤快的调度员。想清楚这一点很多设计焦虑会瞬间消失——你不需要在代码里写思考逻辑你只需要保证模型每次思考时,手里拿到的是干净、准确、足够的信息。1.2 为什么我建议第一版坚决不用现成框架这不是说框架不好而是排错时你必须知道每一层在干什么。用框架搭的智能体出问题时常见现象是它明明有工具却不用或者它一直重复调用同一个工具。这类现象在框架里可能来自工具描述被改写了、消息格式被重组了、重试逻辑被自动触发了你翻文档翻半天也定位不到。自己写一百行出问题时print(messages)一眼就能看到模型到底看到了什么、回了什么、工具返回了什么。我做过对比同样的一个任务自己写的版本调试平均耗时是框架版本的几分之一因为你没有任何黑盒。还有一个更实际的理由框架会替你做很多你没意识到的决定。它可能自动给你的系统提示词追加一段约束可能把工具返回结果重新格式化可能在背后加了重试和反思的额外调用。这些默认行为在 demo 阶段看着挺贴心一旦你要精确控制成本、控制延迟、控制输出格式它们就变成需要反向拆解的负担。先用一百行把地基踩实之后再上框架你会清楚地知道每一层抽象在替你还什么债。提示第一版代码里不要引入任何异步、并发、队列。虽然并发执行工具调用确实能省时间但它会把报错堆栈搅成一团。先把串行版本跑通等你能闭着眼睛说出每一轮消息的顺序再谈并发。2. 工具注册表决定上限的其实是说明书不是代码2.1 工具描述写不好模型就是在瞎猜这是我在实际项目里踩得最深的一个坑也是多数人最忽略的地方。工具的函数体决定了它能做什么但工具的描述文字决定了模型会不会用、什么时候用、参数填得对不对。函数体写得再漂亮描述写成计算东西这种模型基本靠猜。我拿一个真实对比说明。同一个计算工具两种写法# 写法 A给自己看的注释 description: 计算 # 写法 B给模型看的说明书 description: 计算一个纯算术表达式支持 - * / % ** 和括号。 遇到任何需要数值运算的问题都必须调用本工具 不要自己心算。参数只能是数字和运算符不能包含变量名。写法 A 之下模型有相当概率不调用工具直接凭记忆给出计算结果然后在三位数乘法上翻车或者反过来在明明不需要计算的问题上也硬调一次。写法 B 之下调用率和正确率都会明显上去。这里的规律很朴素模型对工具的选择完全基于描述文本的语义匹配描述里没提到的场景它就认为这个工具不该管。我总结了几条写描述的硬经验。第一说清楚什么时候用比说清楚这是什么更重要最好写成触发条件的形式凡是涉及 X 的情况都调用本工具。第二明确禁止项不要用于 Y这类否定句能有效减少误用尤其是当两个工具功能相近时。第三在参数描述里给例子参数expr的描述写成如 (128)*3比写成算术表达式的填参正确率高得多。第四把返回值的形态也写进去返回字符串形式的数值能让模型知道下一步怎么处理。2.2 一个字典加一个装饰器就够了工具注册表的实现可以极简。核心数据结构是一个全局字典键是工具名值里存两样东西给模型看的 schema和自己真正要执行的函数。用装饰器注册是为了让定义工具和注册工具写在一起避免两边维护不同步。import json, re REGISTRY {} def tool(name, desc, schema): def wrap(fn): REGISTRY[name] { fn: fn, spec: {type: function, function: { name: name, description: desc, parameters: schema}}, } return fn return wrap tool( calc, 计算一个纯算术表达式支持 - * / % ** 和括号。 凡是需要数值运算的场景都必须调用本工具不要心算。, {type: object, properties: {expr: {type: string, description: 如 (128)*3}}, required: [expr]}, ) def calc(expr): if not re.fullmatch(r[0-9\-*/%(). ], expr): return 表达式含非法字符只允许数字和运算符 return str(eval(expr, {__builtins__: {}}, {}))这三十行里REGISTRY是唯一的状态tool装饰器负责把函数和它的说明书绑在一起calc是具体实现。要加新工具再写一个装饰器函数就行主循环完全不用改。这种设计的好处是加工具的成本接近于零而智能体的能力边界几乎完全由工具集决定所以这个低成本扩展点非常值。2.3 参数校验必须自己写不能指望模型有个细节很多人会漏模型生成的参数是不可信的。它可能传进来一个表达式里带变量名可能传进来null可能把数字写成字符串12也可能少传必填字段。这些情况如果不拦轻则工具返回一堆看不懂的异常文本污染上下文重则执行了不该执行的操作。我现在的做法是三层防护。第一层在 schema 里声明required这能挡掉大部分缺字段的情况但不是全部。第二层在每个工具函数内部做入参检查像上面calc里的正则非白名单字符直接返回一句人类可读的拒绝理由——注意是返回字符串而不是抛异常因为这句话会作为工具的观察结果回到模型上下文里模型看到表达式含非法字符会自己修正看到一长串 Python traceback 大概率会懵。第三层在主循环里统一兜底捕获所有异常并转成短文本。三层叠起来一个工具写坏也不会把整个循环带崩。注意任何涉及文件系统、网络请求、数据库写入的工具路径和参数必须在函数内部做白名单校验。不要因为模型应该不会这么填就省掉这一步模型幻觉出一个../../etc/passwd这种事发生概率比你想的高。3. 主循环的骨架把想一想、做一做、看一看写成 while3.1 一轮完整循环到底发生了什么我把一轮循环拆成六个动作写代码时按这个顺序对照基本不会漏东西。第一个动作组装消息列表把系统提示词、用户任务、以及之前所有轮次的对话和工具结果按顺序排好。第二个动作把消息列表和工具清单一起发出去。第三个动作拿到回复后先检查有没有工具调用请求这个判断是整个循环的分岔点。第四个动作如果没有工具调用说明模型认为可以直接回答了循环结束把文本返回。第五个动作如果有工具调用逐个执行把每个结果包装成工具返回类型的消息追加进历史。第六个动作回到第一个动作进入下一轮。这里最容易写错的是第五步的消息组装。工具返回的消息必须携带对应的调用 ID跟模型的请求一一配对顺序也不能乱。如果配对错了接口会直接报格式错误如果漏了 ID模型会不知道这个结果是回应哪一次调用的行为会变得奇怪。我第一版就是手动拼字符串结果三个工具并发调用时 ID 全串了调了两小时才发现。def run(task, max_steps8): messages [{role: system, content: SYSTEM_PROMPT}, {role: user, content: task}] trace [] for step in range(max_steps): msg llm(messages, tools[v[spec] for v in REGISTRY.values()]) calls msg.get(tool_calls) or [] messages.append({role: assistant, content: msg.get(content) or , tool_calls: calls} if calls else {role: assistant, content: msg.get(content) or }) if not calls: # 没有动作 想好了 return {answer: msg.get(content, ), steps: step 1, trace: trace} for c in calls: # 有动作 去执行 name c[function][name] raw c[function].get(arguments) or {} try: args json.loads(raw) result REGISTRY[name][fn](**args) if name in REGISTRY \ else f不存在工具 {name}可用{list(REGISTRY)} except json.JSONDecodeError: result 参数不是合法 JSON请重新生成 except TypeError as e: result f参数不匹配{e} except Exception as e: result f工具执行异常{e} result str(result)[:2000] # 截断防撑爆上下文 trace.append({tool: name, args: raw, result: result}) messages.append({role: tool, tool_call_id: c[id], content: result}) return {answer: 达到最大步数仍未收敛, steps: max_steps, trace: trace}这段四十来行是整个智能体的心脏。trace那个列表是我强烈建议加的它记录每一次工具调用的名字、参数和结果出问题时不用打日志直接看trace就知道模型走的是哪条路、在哪一步拐错了弯。生产环境里这个字段还能直接当可观测性数据用。3.2 原生工具调用和文本协议两条路怎么选上面代码走的是原生工具调用这条路把工具 schema 直接传给模型模型返回结构化的调用请求。这条路的好处是解析零成本、参数准确率高、格式由服务端保证。坏处是它对模型和接口有要求一些较小的本地模型或者较老的接口不支持。另一条路是文本协议不传 schema全靠系统提示词约定输出格式比如要求模型需要调用工具时输出一行ACTION: {tool: calc, args: {expr: 11}}然后你自己用正则去抓。这条路通用性极强任何能对话的模型都能用但稳定性差不少——模型可能忘了格式、可能加个反引号包起来、可能在一段解释文字中间夹一句伪代码让你误抓。对比项原生工具调用文本协议参数准确率高结构化生成中依赖模型遵守格式解析代码几乎为零需要正则 容错模型兼容性需接口支持任何对话模型多工具并发调用原生支持需自己设计分隔符调试难度低结构清晰高格式问题花样多我的选择建议很简单能走原生就走原生实在不支持再退回文本协议。同时补一句用文本协议时一定要在提示词里给两三个完整的正例并且明确写除了 ACTION 行之外不要输出其他内容这两条能把稳定性拉回来一大截。3.3 退出条件写不严谨程序会自己转到天亮max_steps是最后一道保险但它不该是唯一的退出条件。我见过最典型的事故是模型在某个工具上反复拿到同样的报错于是不断重试一直转到步数上限才停中间烧掉的调用次数和时间都很可观。所以除了步数上限我一般还加三个判断。第一连续重复动作检测。把每次调用的(工具名, 参数)拼起来做个指纹如果连续两轮指纹完全相同就往上下文里注入一条提醒你刚刚已经用完全相同的参数调用过这个工具结果没有变化请换一个思路或直接给出结论。实测这条提示能救回相当多的死循环。第二总调用次数预算。步数上限管的是循环几轮但一轮里可能有多个工具调用。加一个全局计数器超过阈值直接终止并返回当前trace让上层决定怎么处理。第三空转检测。如果模型连续两轮既不调工具、也不给出实质内容比如只回一句好的我继续说明它卡住了。这种情况在上下文被截断得比较狠时容易出现因为模型丢失了任务目标。def should_stop(trace, step, max_steps, max_calls12, max_repeat2): if step max_steps: return 步数超限 if len(trace) max_calls: return 调用次数超限 if len(trace) 2: a, b trace[-2], trace[-1] if (a[tool], a[args]) (b[tool], b[args]) and max_repeat 2: return None # 不终止但触发提醒注入 return None逻辑不复杂但它把一个可能失控的循环变成了最多花这么多钱的确定性程序。做智能体最怕的不是效果差是账单不可预测。4. 上下文管理100 行里最容易被低估的部分4.1 工具返回值是真正的 token 黑洞很多人以为上下文膨胀是因为对话轮次多其实不是。真正吃 token 的是工具的返回结果。一个读文件的工具随手返回两万字一个搜索工具返回十条每条五百字的摘要一个接口调用工具返回一大坨没裁过的 JSON。跑五六轮上下文就顶到上限了然后接口报错或者模型开始遗忘前面的任务目标。我在一个小任务上做过统计一次完整的八轮循环messages里工具返回的内容占了总字符数的九成以上模型自己说的话加起来不到一成。这个比例很有说服力——如果只做一项优化那一定是在工具返回时立刻截断。上面代码里那句str(result)[:2000]就是干这个的简单粗暴但极其有效。截断也有讲究。不要按字符硬切切在句子中间模型容易误读。我一般让工具自己负责截断并且明确告诉模型已截断比如文件读取工具返回前四千字符末尾追加一句内容已截断如需后续部分请指定偏移量。这句话很重要它让模型知道信息不完整而不是以为文件就这么短从而得出错误结论。4.2 三步裁剪法让长任务也能跑完当任务确实需要跑很多轮时光靠截断不够还需要对历史做裁剪。我的做法分三步从轻到重。第一步是合并中间过程。对于早期的、已经得出结果的工具调用轮次把它们压成一句话摘要比如把三轮的文件读取和计算压缩成已读取 a.txt 并计算出均值为 42.7。这一步需要额外调一次模型来做摘要成本上算得过来因为省下的输入 token 通常远多于这次调用的开销。第二步是保留首尾。系统提示词和用户原始任务永远保留在最前面最近的 N 轮完整保留中间部分整体压缩。这样模型始终记得我要干什么和我刚才在干什么只丢失我很久以前做过什么细节。第三步是把关键中间结论外置。如果某个计算结果后面还要反复用到与其让它躺在历史里被裁掉不如让模型主动把它写进一个工作笔记占位符里每轮都带上。这个技巧听着土但在多步推理任务上效果出奇地好因为它把易失的上下文变成了结构化的固定字段。def trim(messages, keep_head2, keep_tail8, budget_chars40000): total sum(len(m.get(content) or ) for m in messages) if total budget_chars: return messages head, tail messages[:keep_head], messages[-keep_tail:] middle messages[keep_head:-keep_tail] # 中间部分只保留工具名和结果前 200 字压成一行摘要 squeezed [{role: system, content: 早期步骤摘要 .join( f{m.get(role)}{(m.get(content) or )[:200]} for m in middle)}] return head squeezed tail这个函数当然不完美压缩质量取决于你怎么写摘要逻辑但它的存在本身就让程序不会在中途崩掉。我个人的经验是上下文管理做得好不好直接决定你的智能体能跑三步还是能跑三十步而它往往只占十几行代码。5. 实测撞到的五个坑以及我是怎么改的5.1 工具参数幻觉名字对参数离谱第一个高频问题。模型选对了工具但参数填得莫名其妙让它算(128)*3它传{expr: (128)*3 的结果}让它读notes.txt它传{path: ./notes.txt}或者干脆传个不存在的文件名。这类问题的根因是模型在补全而不是在填表它会顺着语义把参数写成一句自然语言。改法有两层。表层是在参数描述里把格式约束写死path的描述写成相对路径不含./前缀如notes.txt尽量给具体例子。深层是在工具函数里做宽松解析路径统一去掉./前缀表达式里如果混入中文或空格就先清洗再校验。不要指望一次就把模型的习惯纠正过来工程上更靠谱的做法是让工具容错而不是让模型完美。5.2 重复调用同一个工具结果一模一样第二个坑前面提过这里说它的完整排查链路。我第一次遇到时以为是代码 bug加了日志才发现模型连着三轮调用read_file参数完全一样。往上看上下文原因是第一次读取返回了读取失败文件不存在模型没有正确理解这是终态错误而是认为可能刚才没读成功再试一次。定位过程很直白打印trace看连续几轮的(tool, args, result)三元组有没有重复。修复方案是两个动作配合一是在工具的错误信息里明确写清终态文件不存在请检查路径不要重复调用本工具二是在主循环里加重复指纹检测连续两次相同就注入提醒。两个动作做完这类死循环基本消失。5.3 结果看着对其实错因为工具撒谎了这个坑最阴。工具返回了内容模型也正常总结了但结论是错的因为工具本身返回了误导性信息。典型场景读文件工具在文件不存在时返回空字符串模型看到空字符串得出文件内容为空的结论然后基于这个错误前提继续推理后面全错。根因在于空值和失败被混为一谈了。修复原则只有一条工具的所有失败路径都必须返回明确的、带失败字样的文本绝不返回空串、None、0或者裸的异常对象。我现在写工具有个固定模板任何分支都要有返回值且失败时以错误开头。这个约定看起来啰嗦但它让模型能清楚区分没有数据和取数据失败而这两种情况的后续处理完全不同。现象根因改法参数填成自然语言模型在语义补全而非填表参数描述给具体示例 工具内宽松清洗同参数反复调用错误信息没体现终态错误文案写不要重试 指纹重复检测结论基于错误前提失败返回空值所有失败分支返回错误xxx越跑越跑偏上下文被截断丢了目标首尾保留 关键结论外置一次异常整个崩掉没做分层捕获工具内捕获 → 循环内兜底 → 返回错误文本5.4 长任务跑到后面目标漂移第四个坑出现在步数超过六轮之后。模型开始忘记最初的约束比如用户要求只用工作目录下的文件跑到第八轮它开始尝试读绝对路径。这不是模型变笨了是原始指令在上下文里的相对权重被稀释了。我的改法是两步。第一把最关键的约束从系统提示词里提权在每一轮的用户消息前面重新附加一句极短的提醒比如提醒只允许访问工作目录。第二把任务目标本身做成一个结构化字段放在消息列表的固定位置裁剪时永不删除。这两个动作加起来不到十行但对长任务的稳定性提升非常明显。5.5 一个异常把整个循环打断最后一个坑最基础但新手最容易踩工具函数里抛了异常没有捕获整个run直接挂掉前面所有轮次的工作全丢。修复方法就是第 3 节代码里那三层try。这里有个分寸问题捕获范围要足够大但错误信息要足够短。原始 traceback 对调试有用对模型没用反而会占用大量上下文。我一般只取异常类型和前一百字消息拼成工具执行异常XXX返回给模型完整的 traceback 打到本地日志。6. 从 100 行往上长哪些该加、哪些是过早优化6.1 值得优先加的三样东西跑通之后你会自然想扩展。按投入产出比排序我建议先加这三样。第一样是工具集本身。前面说过智能体的能力上限由工具决定加一个高质量工具的收益远大于改十行提示词。优先级上先补齐读的能力读文件、查时间、做 HTTP 请求、查本地数据再补写的能力写文件、发请求。写操作要格外小心最好加一道人工确认或者干跑模式。第二样是结构化输出。让最终的answer不是一段自由文本而是固定字段的 JSON比如{conclusion: ..., evidence: [...], confidence: high|low}。这一改动让智能体的输出可以被程序消费接进流水线才有意义。实现方式是在系统提示词里给出严格的 JSON 模板并在拿到结果后做一次解析校验解析失败就再问一次。第三样是执行轨迹的持久化。把每轮的trace存下来格式简单点就行。它有三个用处出问题时能完整回放可以统计哪类工具调用最频繁、哪类经常失败积累足够多之后能反过来优化工具描述和提示词。这是从玩具走向工具的关键一步成本极低。6.2 可以往后放的三样东西与之相对有些东西看起来高级但在你这个阶段加进去纯属自找麻烦。向量检索。除非你的任务确实需要从大量非结构化文档里找片段否则别加。加进来意味着你要处理切片策略、嵌入模型、召回质量、重排每一个都是独立的问题域而且它跟你验证智能体循环是否跑通这件事完全无关。多智能体协作。这个概念听着很美实际落地时通信开销和错误传播会把收益吃掉。单智能体能完成的任务拆成三个智能体互相商量通常只会更慢更贵更不稳定。我见过的最小可行做法是先做单智能体只有当某个子任务确实需要完全不同的工具集和提示词时才考虑拆出去。长期记忆。跨会话记忆在 demo 里很酷但在真实使用中它带来的主要是上一轮的脏数据污染了这一轮的问题。等你把单次任务的稳定性做到让人放心了再考虑记忆的事。提示每次想加功能前问自己一句——不加这个我的智能体现在能跑通吗如果答案是能那就先别加。这句话帮我省掉了至少三次重构。我个人在实际操作中的体会是一百行的价值不在于它短而在于它每一行你都能解释得清。当你需要给一个跑歪的智能体做诊断时最有效的动作永远是把这一轮它看到的完整上下文打印出来从头到尾读一遍。你会发现绝大多数模型不听话本质是它看到的信息有问题——工具返回了空值、描述写得不清楚、历史被裁得太狠。这一百行给了你一个可以完整审视的最小闭环而这件事比任何框架特性都重要。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑