在开发 MolaGPT 的过程中,我始终认为,单纯的文本对话只是 AI 模型能力的一种表象。而真正激动人心的是模型能够主动理解意图,和人类一样制定计划,执行复杂指令来解决复杂问题,比如调用工具、生成图表、分析数据等。
要实现这一点,就需要为模型构建一套完整的“工具箱”,还需要有强大的语言模型本身具有调用工具的能力,而这些能力就需要配套的后端接口和稳定的结果输出。
这段时间,我终于有机会着手开发一个基于 Function Calling(现在貌似 Tools Calling)的 Agent. 我的目标很明确:首先,赋予模型联网搜索的能力;其次,也是更关键的,让模型能够运行 Python 代码。
手搓复刻 OpenAI 的 Function Calling
我的后端整体的核心思路借鉴了 OpenAI 的 Function Calling 机制。其原理可以概括为:当模型识别到用户意图需要借助外部工具才能完成时,它不再直接生成最终答案,而是生成一个结构化的 JSON 对象。这个 JSON 对象精确描述了需要调用的函数名称(例如 execute_python_code)以及执行该函数所需的参数(例如,一段 Python 代码字符串)。
这个结构化的请求随后被发送到我的后端系统。后端接收到这个请求后,会解析 JSON 内容,根据 name 字段匹配预先定义好的可用函数列表 ($available_functions),找到对应的处理逻辑(比如说 execute_python_code)。后端执行完任务后,再将执行结果(比如代码的标准输出或错误信息)返回给模型进行二次请求,随后模型结合上下文,生成最终回复给用户。
举个例子,如果模型判断需要执行 Python 代码 print(2+2),它会向后端发送类似以下的 JSON:
{"name":"execute_python_code","arguments":{"code":"print(2+2)"}}后端解析出 name 为 execute_python_code,提取 arguments 中的 code 字段 "print(2+2)",然后将其交给一个隔离的 Docker 沙箱环境中的 Python 解释器执行。执行完毕后,捕获其 stdout,并将 "4" 这个结果返回给模型。这通常涉及两次与模型的交互:一次是模型输出 Function Call 请求,一次是后端返回执行结果供模型参考。
例如,在我的后端实现中,我为模型定义了两个核心函数:
search_web:用于联网搜索最新信息。
execute_python_code:用于执行 Python 代码,也是本文讨论的重点。
这是后端定义的函数描述信息,用于告知模型这两个工具的存在和使用方法:
$available_functions=[["type"=>"function","function"=>["name"=>"search_web","description"=>"在网络上搜索最新信息,获取权威内容(Searchthewebforthelatestinformationandauthoritativecontent)","parameters"=>["type"=>"object","properties"=>["query"=>["type"=>"string","description"=>"搜索查询内容(Thesearchquery)"]],"required"=>["query"]]]],["type"=>"function","function"=>["name"=>"execute_python_code","description"=>"运行一段Python代码,并返回标准输出结果(ExecuteasnippetofPythoncodeandreturnthestandardoutput)","parameters"=>["type"=>"object","properties"=>["code"=>["type"=>"string","description"=>"要执行的Python代码(ThePythoncodetoexecute,e.g.,print(2+2))"]],"required"=>["code"]]]]];
模型会根据对话上下文,智能判断是否需要调用这些工具,并自动生成相应的参数请求,交由后端执行。
过程中遇到的几个问题和解决
1. 模型并非总会使用 print()
在初步测试 execute_python_code 函数时,我发现模型生成的代码经常执行失败。查看 Log 发现,问题在于模型生成的代码很多时候没有显式地使用 print() 函数来输出最终结果,模型也许把后端的 Python 环境当做了Jupyter Notebook 这种交互式环境了。例如,当我让模型计算 2+2 时,它可能会生成如下代码并请求执行:
2+2## 或者result=2+2result
在 Jupyter Notebook 等交互环境中,这么写确实没问题,可是我的后端是单纯的拉取 Python 镜像人为构建的 Docker 环境,Python 执行器运行这段代码之后,由于没有print()语句,标准输出就为空。这样一来,模型就无法接收到计算结果 "4",导致其认为代码执行失败。
我最初的想法很简单:是不是可以在接收到的代码字符串末尾自动加上print(...)?但很快意识到这种方法过于粗暴。并非所有代码执行都需要打印最后一个表达式的结果,例如代码可能只是定义函数、导入库等等,确实不需要print()的存在。强行添加print()可能导致语法错误或非预期的行为。
经过一番资料查阅和向大模型“请教”,我决定采用 AST(Abstract Syntax Tree,抽象语法树)来智能地处理这个问题。AST 可以将代码解析成一个树状结构,其中的每个节点代表程序中的一种语法元素,比如表达式、语句、函数定义等。这个树的根是整个模块,而它的子节点可能是赋值语句、函数调用、条件判断等结构。
使用 AST 的主要目的就是精确地表达代码的语法和语义,忽略掉注释、空行等非实质性内容。而不是像之前那样去暴力添加print().
importastclassAutoPrintTransformer(ast.NodeTransformer):"""自动为最后一个表达式添加 print() 的 AST 转换器"""def__init__(self):super().__init__()self.modified =Falsedefvisit_Module(self, node):"""处理模块节点,为最后一个表达式添加 print()"""self.generic_visit(node)last_expr_index = -1foriinrange(len(node.body) -1, -1, -1):stmt = node.body[i]# 判断是否为 print 语句is_print_stmt = (isinstance(stmt, ast.Expr)andisinstance(stmt.value, ast.Call)andisinstance(stmt.value.func, ast.Name)andstmt.value.func.id=='print')is_docstring = (isinstance(stmt, ast.Expr)andisinstance(stmt.value, ast.Constant)andisinstance(stmt.value.value,str)andi ==0)ifnotis_print_stmtandnotis_docstring:last_expr_index = ibreak# 找到了需要添加 print 的表达式iflast_expr_index != -1:expr_node = node.body[last_expr_index]print_call = ast.Call(func=ast.Name(id='print', ctx=ast.Load()), args=[expr_node.value], keywords=[])print_stmt = ast.Expr(value=print_call)ast.copy_location(print_stmt, expr_node)node.body[last_expr_index] = print_stmtself.modified =Truereturnnode
这段代码的主要执行流程为:
ast库,通过ast.parse()将模型生成的代码字符串解析成一个 AST 对象。(node.body)。ast.Expr的语句节点。print()print()调用,或者是否是一个字符串常量。print()函数调用节点中,并替换掉原来的表达式语句节点。ast.unparse()将修改后的 AST 转换回 Python 代码字符串,或者直接编译并执行。通过这种方式,即使模型没有写print(),我们的后端也能找到所有漏掉的位置并动态补齐print(),确保计算结果能够被正确捕获并返回。
1.3 随后遇到的新问题
在上线一段时间后,我发现 PHP 在处理 Python 代码的引号和换行时存在严重问题。由于直接将 Python 代码塞进 Python 模板字符串时,经常因为代码内部含有特殊字符(如单引号、双引号、反斜杠等)或复杂的多行结构,导致字符串提前中断或发生语法错误。这是我始料未及的,因为之前做 PHP 开发时也没有直接在 PHP 代码中写 Python,没想到会有这种问题。
为彻底解决这一问题,我决定进一步优化:在 PHP 中先将用户的 Python 代码进行 Base64 编码,这样就能确保传递到 Python 环境中的字符串是原生的且不受特殊字符干扰。
1.4 现行解决方案:利用 Base64 来保持代码完整性,随后再利用 AST 自动补全 print()
后端在接收模型提交的 Python 脚本后,使用 PHP 的内置函数base64_encode对 Python 代码进行编码。
$b64=base64_encode($user_python_code);
Python 侧模板不再直接使用三重引号夹入用户脚本,而是接收一个 Base64 字符串,然后通过 Python 的内置库base64.b64decode()将其还原成原始代码,再利用之前开发的 AST 自动补全print()的方法进行处理。
importbase64, ast, tracebackclassAutoPrintTransformer(ast.NodeTransformer):def__init__(self):self.has_modified =Falsedefvisit_Module(self, node):self.generic_visit(node)last_expr =Noneforiinrange(len(node.body) -1, -1, -1):stmt = node.body[i]ifisinstance(stmt, ast.Expr)andnot(isinstance(stmt.value, ast.Constant)andisinstance(stmt.value.value,str)):last_expr = (i, stmt)breakiflast_expr:idx, expr = last_expris_print = (isinstance(expr.value, ast.Call)andisinstance(expr.value.func, ast.Name)andexpr.value.func.id=='print')ifnotis_print:print_node = ast.Expr(value=ast.Call(func=ast.Name(id='print', ctx=ast.Load()),args=[expr.value],keywords=[]))ast.copy_location(print_node, expr)node.body[idx] = print_nodeself.has_modified =Truereturnnodedeftransform_code(src):try:tree = ast.parse(src)transformer = AutoPrintTransformer()new_tree = transformer.visit(tree)ast.fix_missing_locations(new_tree)iftransformer.has_modified:exec(compile(new_tree,'<string>','exec'),globals())else:exec(src,globals())exceptExceptionase:print(f"处理代码时出错:{e}")traceback.print_exc()exec(src,globals())# 从 Base64 解码恢复原始用户代码user_code = base64.b64decode('<Base64_encoded_string>').decode('utf-8')transform_code(user_code)
上述解决方法上线后,避免了 PHP 与 Python 代码交互过程中字符串意外截断的问题,带有复杂换行结构的 Python 可正确执行。
为了增强代码执行能力,我在构建 Docker 镜像时特意预制了 matplotlib 库,期望模型能够生成数据可视化图表。在测试中,模型确实能生成标准的绘图代码(例如绘制正弦波),并且也调用了 plt.show(),但是代码执行后,前端聊天界面却看不到任何图像,不过想想也对,这是当然的,因为 plt.show() 会尝试在图形用户界面(GUI)环境中打开一个窗口来显示图像。但在 Docker 沙箱中没有显示器或窗口系统,纯纯就是无头的环境 plt.show() 会静默失败或报错。
有人会觉得(这个人就是我):那能不能在 Prompt 中让模型保存图片呢?可这就出现了一个新的问题:即使模型改为调用 plt.savefig('plot.png') 将图像保存到文件,这个文件也只是存在于容器内,无法被外界直接访问,更何况一旦代码运行完毕,容器销毁之后数据就会丢失。
前置代码注入:执行模型代码前,在 header 区域注入一段作为补丁的代码,追踪所有创建的 Figure 对象,并设置 matplotlib 使用非交互式后端:
importosimportuuidimportmatplotlibmatplotlib.use('Agg') # 使用非交互式后端importmatplotlib.pyplotaspltfig_list = [] # 用于跟踪所有创建的图orig_figure = plt.figure # 保存原始的 plt.figure 方法deftrack_figure(*args, **kwargs):"""寻找生成的图形。"""fig = orig_figure(*args, **kwargs)fig_list.append(fig)returnfigplt.figure = track_figure
后置代码注入:在代码执行结束后,遍历所有图像对象,将它们保存为 PNG 文件,透传到本地目录中,并将路径转换为公网可访问的图像链接:
...saved_paths = []if'fig_list'inglobals()andfig_list:output_dir ="/output"# 输出目录os.makedirs(output_dir, exist_ok=True)fori, figinenumerate(fig_list):unique_id =str(uuid.uuid4())[:8] # 生成唯一 IDfilename = os.path.join(output_dir,f"chart_{i}_{unique_id}.png") # 构建文件名try:fig.tight_layout()exceptException:passtry:fig.savefig(filename, dpi=100, bbox_inches='tight') # 保存图像saved_paths.append(filename) # 添加路径到列表exceptExceptionase:print("出错!!")finally:plt.close(fig) # 关闭图形ifsaved_paths:forimg_pathinsaved_paths:print(f"###IMAGE_FILE_PATH###{img_path}")else:print("没有图表。")plt.close('all'
模型处理逻辑:并使用着重符号 ###IMAGE_FILE_PATH### 来提示模型当前位置有图片,使用让模型可以理解的自然语言来输出 Markdown 格式的图表:

一系列处理后,模型所绘制的图像就能在前端被展示给用户了。
如上图所示,用户提出让模型绘制一个正四棱台,模型理解了用户的意图并绘制出了一个美丽的图像,通过后端预处理逻辑被成功透传到公网目录中。
3.总结
不管温度参数设定如何,由于 Python 语法的等效性,模型依然可能会不使用print();或是 matplotlib 图像不显示。这时候就需要开发者在后端设计好相应的预处理、执行和后处理机制,就能有效地弥补 Agent 开发过程中的局限性,为模型“擦好屁股”。
| 欢迎光临 链载Ai (https://www.lianzai.com/) | Powered by Discuz! X3.5 |