☰
原生手写Python打造简易Agent:模型调用、工具注册与循环控制全解析
2026/11/3 7:31:50 网站建设 项目流程

前几天帮团队做Agent方向的技术面试,面了一圈候选人,发现一个很有意思的现象:聊LangChain、AutoGen、Dify这些概念,大家都能聊几句,真把一份模型返回的原始响应丢到屏幕上,让人手写一个Agent工具调用循环,很多人就卡住了。原因不复杂——框架把Agent最核心的骨架封装得太好,大家平时写业务代码,根本碰不到“模型输出解析”“工具注册”“循环退出”这些底层环节。但这些东西偏偏是Agent面试最爱问、也最能看出候选人是“真懂”还是“熟手”的地方。

这篇文章我不谈花哨的编排框架,完全用原生手写Python的方式,从零搭一个能跑起来的简易Agent:包含模型调用封装、工具实现与注册、模型输出解析、多轮对话循环。目标很直接:让你看完之后,不但能在面试里把“原生手写Agent”讲清楚,还能直接动手复现。适合两类人:一类是准备Agent方向面试的同学,另一类是已经用上框架、但想搞清楚底层到底发生了什么的开发者。

1. 为什么“原生手写”是Agent面试躲不开的一道坎

1.1 框架把所有关键逻辑藏得太深了

市面上主流的Agent框架做得越来越好,好处是上手门槛低,坏处是很多人把框架当成黑盒在用。我面试时最喜欢问的一句话是:你的Agent在跑一个工具调用时,消息列表里多出了哪几条记录?框架底层替你做了什么?

这个问题能把“会用框架”和“理解Agent”的人迅速分开。框架封装了ReAct循环、工具调用解析、异常重试、上下文压缩,表面上你用几行代码就搭好了一个Agent,但真出问题的时候,比如模型返回的JSON解析失败、工具结果没有正确回传、循环没有正常终止,很多人根本不知道从哪下手排查。

面试官问你原理,不是为了刁难你,而是为了确认一件事:你能否在框架失效的场景下独立解决问题。Agent方向的岗位本质上是做推理调度和工程兜底,不是堆业务代码。原生手写一遍,就是检验你是否具备这种能力的最直接方式。

1.2 手写Agent到底在写什么

很多人一听“手写Agent”就慌,以为要从零训练一个大模型,其实完全不是。手写Agent是在写一个围绕模型能力构建的工程系统,核心模块就四个:

  • 模型调用封装:把用户问题和历史上下文发给模型,拿到模型回复。
  • 模型输出解析:把模型回复里的工具调用意图解析成结构化指令。
  • 工具执行与分发:根据解析结果去调用对应工具,拿到真实结果。
  • 循环控制:把工具结果回传给模型,让模型继续推理,直到不再需要调用工具。

这四个模块没有哪个是高深数学,都是在做工程落地。但每一个都藏着一堆实际场景里的坑。比如模型输出到底长什么样?工具结果怎么回传?什么时候该终止循环?这些坑不亲手踩一遍,框架永远不会教给你。

我经常把Agent类比成一个实习生:模型是实习生的大脑,工具是实习生能调用的资源,输出解析是“听懂他含糊的话并转成明确指令”的翻译官。框架帮你雇了一个训练有素的实习生,但如果你连实习生的工作汇报格式都看不懂,那就没法真正管理他。手写Agent,就是把这个翻译官自己建一遍。

2. 最小Agent骨架:循环体、消息流与停止条件

2.1 把“Agent循环”用代码固定下来

Agent和普通Chat应用最大的区别,在于它有“感知-决策-行动”的循环。普通对话是一次请求一次响应,Agent是在一次请求内多次调用模型,中间穿插工具执行。这个循环是Agent的心脏,手写的第一步就是把它写成一个稳定的循环体。

用一个很朴素的结构来描述:

def run_agent(user_input, system_prompt, tools, tool_map, max_iter=8): messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_input}, ] for step in range(max_iter): resp = call_llm(messages, tools) assistant_msg = resp["message"] messages.append(assistant_msg) # 如果模型没有要求调用工具,直接返回最终回答 if not assistant_msg.get("tool_calls"): return assistant_msg.get("content") # 遍历模型要调用的工具,逐个执行并回传结果 for tool_call in assistant_msg["tool_calls"]: fn_name = tool_call["function"]["name"] arguments = json.loads(tool_call["function"]["arguments"] or "{}") result = dispatch(tool_map, fn_name, arguments) messages.append({ "role": "tool", "tool_call_id": tool_call["id"], "content": json.dumps(result, ensure_ascii=False), }) # 走到这里,代表本轮已经执行了工具,下一轮让模型基于工具结果继续推理 return "已达到最大迭代次数,Agent停止。"

这段代码是整个Agent的骨架核心。拆开看,每一步都不复杂:

  • 每轮先让模型基于当前消息列表做一次决策,模型可能直接回答,也可能请求调用工具。
  • 如果模型请求调用工具,就执行工具,并把工具结果以一条新的消息追加进列表。
  • 然后进入下一轮循环,让模型看到工具返回的真实结果,继续判断是继续调用工具还是最终回答。

需要特别留意的是停止条件。Agent循环不能无限跑下去,我的习惯是设置最大迭代次数,一次Agent任务一般控制在8到15轮以内。如果超过这个范围,要么是模型陷入反复调用工具的怪圈,要么是工具设计有问题,这时候直接终止并返回错误或当前状态,比硬着头皮跑下去更明智。

2.2 messages列表就是Agent的“记忆”

Agent的所有推理依据,都来自消息列表。写原生实现时最大的收获,就是对消息结构的理解会从“听说过”变成“真的懂”。

标准的消息列表大概长这样,按时间顺序排列:

  • system消息:给模型设定总的行为准则和身份。
  • user消息:用户本轮的实际诉求。
  • assistant消息:模型每次输出的回复,可能是纯文本,也可能是带工具调用的结构化消息。
  • tool消息:每个工具执行完后的返回结果,必须和某一条tool_call_id对应。

你可以把整个消息列表理解成一场群聊的聊天记录。模型每次发言前,要看全整个群聊历史,才能知道现在聊到哪了。工具就像一个被@的群成员,它回答完,这条回答也会出现在聊天记录里,模型下一轮就能看到。

这个设计的精妙之处在于:Agent无需额外维护一套复杂的“记忆数据库”,工具执行结果天然就被包含在上下文中,模型可以随时引用。很多刚上手的人容易犯一个错误:把工具结果用别的方式单独存起来,而不是塞回消息列表,这会让模型下一轮完全“失忆”,根本不知道工具返回了什么。

如果你的Agent需要长期记忆,再在这个基础上加向量检索、摘要压缩等机制。但短期记忆的地基,一定就是这一份结构完整、顺序正确的messages列表。原生手写能让你彻底看透这一层。

3. 模型输出解析:把大模型的回答变成可执行指令

3.1 模型到底会返回哪些“幺蛾子”格式

模型不是数据库,不会保证每次输出都是标准JSON。实际项目里我遇到过的情况五花八门:

  • 干净的结构化输出:模型直接返回带tool_calls字段的结构化消息。
  • 解释里夹杂JSON:模型先输出一段“好的,我来调用工具...”,然后才跟着JSON。
  • JSON外面包了markdown代码块:比如json ...。
  • JSON里有中文字符和中文标点:不是标准JSON,解析直接报错。
  • 模型擅自补充字段名:和功能定义不一致,导致参数转发失败。

所以模型输出解析这块,很多框架封装的所谓“智能化”,本质就是一套尽可能鲁棒的解析兜底逻辑。手写时我们要覆盖的,也正是这些真实世界里的脏数据。

3.2 解析原生tool_calls结构

如果用的是支持function calling的模型接口,模型返回一般长这样:

{ "message": { "role": "assistant", "content": null, "tool_calls": [ { "id": "call_001", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\": \"北京\"}" } } ] } }

解析这种结构,关键点是层层取字段,别一口吃成胖子。我习惯用一个专门函数来做这件事:

def parse_tool_calls(message): tool_calls = message.get("tool_calls") if not tool_calls: return [] parsed = [] for tc in tool_calls: fn = tc.get("function") or {} arguments_raw = fn.get("arguments") or "{}" try: arguments = json.loads(arguments_raw) except json.JSONDecodeError: arguments = {"_parse_error": arguments_raw} parsed.append({ "id": tc.get("id"), "name": fn.get("name"), "arguments": arguments, }) return parsed

注意这里有个细节:函数的arguments在API返回里往往是一个字符串,必须先json.loads再使用。很多新手直接把它当dict用,导致工具调用报错,还一脸懵。我一般在解析阶段就做好容错,解析失败就塞一个_parse_error标记进去,让后边的分发逻辑能感知到异常。

3.3 手写ReAct风格的文本解析

如果模型不支持结构化工具调用,那就绕回经典的ReAct模式。模型输出一段带固定格式的文本,比如:

Thought: 用户想知道北京的天气,我需要调用天气工具。 Action: get_weather Action Input: {"city": "北京"}

解析这种文本最朴素有效的办法就是正则,别想得太复杂,够用就行:

import re def parse_react_output(text): action_match = re.search(r"Action\s*:\s*(\w+)", text, re.IGNORECASE) input_match = re.search(r"Action\s*Input\s*:\s*(\{.*\})", text, re.DOTALL | re.IGNORECASE) if not action_match: return None action = action_match.group(1).strip() action_input = {} if input_match: raw = input_match.group(1).strip() try: action_input = json.loads(raw) except json.JSONDecodeError: # 常见问题:模型把中文冒号或者中文逗号写进去了,先做一轮规范化 normalized = raw.replace(",", ",").replace(":", ":").replace("“", "\"").replace("”", "\"") try: action_input = json.loads(normalized) except json.JSONDecodeError: action_input = {"raw": raw} return {"action": action, "action_input": action_input}

正则匹配Action Input时用(\{.*\})配合DOTALL,是为了跨行匹配到完整的大括号块。这个正则不是万能的,但能覆盖九成以上的ReAct输出。如果模型在JSON后面又跟了其他解释性文字,\{.*\}的贪婪匹配会抓到最后一个右大括号,所以基本不会断错位置。

3.4 解析失败最实用的兜底:让模型自己纠错

不管解析逻辑写得多周全,总会有碰到脏数据的时候。这时候最忌讳的就是直接抛异常让整个Agent崩溃。我常用的兜底策略是分级降级:

  • 第一级:尝试从字符串里截取{到}之间的内容,再做json.loads。
  • 第二级:把常见中文标点替换成英文标点后再试一次。
  • 第三级:携带“解析失败的原因”再问一次模型,要求它只输出标准JSON,不要附带任何解释。

第三级看起来笨,但实测非常有效。大模型是文本生成模型,你重新给一次明确指令,它往往就能自我修正。在代码里体现成这样:

if parsed is None: correction_prompt = ( "我刚才的输出格式无法被解析,请重试。" "你必须只输出标准JSON,不要输出markdown代码块,不要添加解释。" ) messages.append({"role": "user", "content": correction_prompt}) continue

把纠错信息作为一条user消息追加进去,让模型在下一次决策时看到,整个循环就还是通的。这个机制其实就是框架里“Retry”的底层实现思路,手写一遍你就知道它一点都不神秘。

4. 工具实现与注册机制:给Agent一双能干活的手

4.1 tool schema怎么写才好用

工具不只是给Agent调用的函数,在模型眼里它是一份“说明书”。模型本质上是在读你的说明书,来判断什么时候该调用、参数该怎么填。所以tool schema写得好不好,直接决定工具调用的成功率。

一个标准的工具定义长这样:

WEATHER_SCHEMA = { "type": "function", "function": { "name": "get_weather", "description": "查询某个城市的当日天气情况。当用户询问天气、气温、降雨、风力等情况时,必须调用该工具。city参数为用户指定的城市中文名。", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市中文名,例如:北京、上海、深圳", } }, "required": ["city"] } } }

这里最值钱的是description。我的经验是:description要写“什么时候用”和“参数怎么填”两块内容。光写“查询天气”太模糊,模型可能在一些边缘场景不知道该不该调用;参数描述写清楚,又能大幅减少模型填错参数的概率。这块花的时间永远不会浪费。

4.2 一个安全可用的计算器工具

很多教程里的计算工具直接拿eval来用,但eval等于把一个可执行环境暴露给了模型生成的内容,非常危险。我更推荐用Python内置的ast模块做白名单式解析,只允许四则运算,其他一律拒绝:

import ast import operator _BINOPS = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, } def _safe_eval(node): if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): return node.value if isinstance(node, ast.BinOp) and type(node.op) in _BINOPS: left = _safe_eval(node.left) right = _safe_eval(node.right) return _BINOPS[type(node.op)](left, right) raise ValueError("不支持的表达式类型") def calculate(expression: str): tree = ast.parse(expression, mode="eval") result = _safe_eval(tree.body) return {"expression": expression, "result": result}

这段代码先用ast.parse把字符串解析成语法树,再逐个节点检查是不是合法的常量或四则运算。遇到函数调用、属性访问、下标这类节点,直接抛异常。这样既保证了计算能力,又封死了模型输出乱指令导致的安全风险。Agent工具的安全意识,是面试官非常看重的一个点。

4.3 注册表与分发逻辑

有了工具函数和schema,还需要一个注册表把它们统一管理起来。最简单的方式就是用字典加装饰器:

TOOL_MAP = {} def register(fn): TOOL_MAP[fn.__name__] = fn return fn @register def calculate(expression: str): # 复用上面的实现 return {"expression": expression, "result": 0} @register def get_weather(city: str): weather_db = { "北京": "多云,气温20-28℃,东南风2级", "上海": "小雨,气温23-27℃,东风3级", "深圳": "晴,气温25-31℃,南风2级", } if city in weather_db: return {"city": city, "weather": weather_db[city]} return {"city": city, "weather": "未收录", "tips": "请确认城市名称"}

分发逻辑也不复杂,拿工具名去查字典,查到就调用,查不到就返回一个明确的错误信息。调用时参数建议用**arguments展开,这样工具函数里的参数名要和schema里的属性名严格一致:

def dispatch(tool_map, name, arguments): fn = tool_map.get(name) if fn is None: return {"error": f"未找到工具: {name}"} try: return fn(**arguments) except TypeError as e: return {"error": f"工具参数错误: {e}"} except Exception as e: return {"error": f"工具执行异常: {e}"}

这里我刻意用try包裹整个调用,目的是把任何异常都转成可读的字符串返回给模型。Agent场景里,工具错误不是“崩溃”而是“一条消息”,模型看到错误信息后,可能换一种方式重试,这个设计对稳定性提升非常关键。

5. 一次完整的Agent工具调用轮次:拆开看每一段消息

5.1 从用户提问到最终回答的完整链路

现在把前边的东西串起来,跑一个真实场景。用户输入:

北京的天气怎么样?顺便算一下 12*34

Agent启动,初始消息列表如下:

[ {"role": "system", "content": "你是智能助手,可以调用工具获取真实信息。当用户需要查询天气或数学计算时,必须调用工具。工具返回后,基于工具结果回答。"}, {"role": "user", "content": "北京的天气怎么样?顺便算一下 12*34"} ]

第一轮调用模型,模型判断需要调用两个工具,返回结构化消息。这时消息列表追加一条assistant消息:

{ "role": "assistant", "content": None, "tool_calls": [ {"id": "call_001", "function": {"name": "get_weather", "arguments": "{\"city\": \"北京\"}"}}, {"id": "call_002", "function": {"name": "calculate", "arguments": "{\"expression\": \"12*34\"}"}} ] }

注意,content是None,因为模型这句“回答”它不是真的回答,而是表达“我要去干活了”。Agent解析出两个tool_call,逐个分发执行。执行完后再追加两条tool消息:

[ {"role": "tool", "tool_call_id": "call_001", "content": "{\"city\": \"北京\", \"weather\": \"多云,气温20-28℃,东南风2级\"}"}, {"role": "tool", "tool_call_id": "call_002", "content": "{\"expression\": \"12*34\", \"result\": 408}"} ]

第二轮再调用模型时,消息列表已经包含了工具结果。模型看到北京天气是“多云,气温20-28℃”,计算结果是408,于是组织一段自然语言回复:

北京今天多云,气温20到28摄氏度,东南风2级。另外,12乘以34等于408。

到此,模型没有继续输出tool_calls,循环终止,返回最终回答。整个链路里工具没有被“外部系统神秘调用”,而是变成了消息列表里的一条普通记录。这就是Agent最朴素的运转机制。

5.2 实测中容易被忽略的三个细节

第一个细节是tool_call_id必须严格对应。工具结果回传时,用的id必须是assistant消息里tool_call里的id。有些接口对不了就报错,有些接口虽然不报错但模型会错乱。手写的时候一定要保持这个映射关系清楚。

第二个细节是多工具调用的顺序。上面示例里两个工具是串行执行的。有些接口一次性返回多个tool_call,有些会要求模型一次只调用一个。如果一次性有多个,我的建议是先逐个执行再统一回传,保证消息列表的顺序和工具执行顺序一致。

第三个细节是工具结果的长度控制。工具返回内容可能很长,比如查询返回一长段日志或一个大列表,全塞进消息列表会导致上下文爆炸。我的习惯是在工具函数里就做裁剪,只返回最关键的字段。如果结果确实很长,就做摘要或截断,加一句“完整数据已省略”。这个处理能显著延长Agent的有效会话深度。

6. 面试复盘:围绕原生实现的六个高频问题

6.1 面试官真正在问什么

准备面试时,别去背现成的“标准答案”,而是通过原生实现的经历去组织回答。我整理了六个我在面试中一定会问的高频问题,每个都对应一套底层逻辑。

高频问题底层考察点参考回答思路
Agent和普通对话应用的本质区别是什么?是否理解循环与工具普通对话是单轮响应,Agent是多轮“决策-工具执行-再决策”的循环
ReAct和function calling有什么异同?是否理解两种主流范式ReAct是文本推理输出动作指令,function calling是接口层结构化声明工具意图
工具结果模型不看怎么办?是否理解prompt引导要在system提示词里强制要求基于工具结果回答,必要时在最终输出前校验
怎么防止Agent死循环?是否理解工程兜底限制最大迭代次数,检测连续调同一工具,工具结果不变时主动终止
上下文满了怎么办?是否理解长对话管理截断历史、摘要压缩、只保留关键工具结果,必要时重置消息列表
工具安全性怎么保障?是否有安全意识工具注册白名单,禁止任意eval,参数严格校验,对敏感操作单独做权限控制

这些问题的核心,其实都是回到那四个手写模块上。你在原生实现中踩过的坑,会自然而然变成面试答案。比如“工具结果模型不看”,你手写几次就会意识到,必须把“基于工具结果回答”写进system prompt,而不是指望模型自己领悟。

6.2 手写与框架不是对立,而是底层能力

我个人的体会是,手写Agent和用框架并不冲突。线上高并发、需要稳定快速落地的项目,我照样会用成熟的框架,因为框架把很多边缘场景都处理好了。但手写过之后,再回去看框架文档,感受完全不同——你一眼就能知道某个参数是干嘛的,某个异常在什么情况下触发,不会再对着报错信息干瞪眼。

有些面试者会问我:“我框架用得很熟练,为什么还要手写?”我的回答是:框架负责帮你省时间,但原理负责让你不掉坑。真正复杂的问题一定出现在框架覆盖不到的边界上,那时候能救你的只有对底层机制的理解。

如果你正在准备Agent方向面试,建议你抽一个周末,别用任何框架,从消息列表开始,把模型调用、输出解析、工具注册、循环控制完整写一遍。写完你会发现自己对Agent的认知会上一个台阶,面试官问起任何底层细节,你都能从自己的代码出发,讲出有理有据的答案。这套路子,我自己就是踩着过来的。

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

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

立即咨询