|
在开发 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 请求,一次是后端返回执行结果供模型参考。 例如,在我的后端实现中,我为模型定义了两个核心函数: 这是后端定义的函数描述信息,用于告知模型这两个工具的存在和使用方法: $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 时,它可能会生成如下代码并请求执行: 在 Jupyter Notebook 等交互环境中,这么写确实没问题,可是我的后端是单纯的拉取 Python 镜像人为构建的 Docker 环境,Python 执行器运行这段代码之后,由于没有print()语句,标准输出就为空。这样一来,模型就无法接收到计算结果 "4",导致其认为代码执行失败。 1.1 失败的尝试:暴力拼接 print()
我最初的想法很简单:是不是可以在接收到的代码字符串末尾自动加上print(...)?但很快意识到这种方法过于粗暴。并非所有代码执行都需要打印最后一个表达式的结果,例如代码可能只是定义函数、导入库等等,确实不需要print()的存在。强行添加print()可能导致语法错误或非预期的行为。 1.2 第一种解决方案:利用 AST 自动补全 print()
经过一番资料查阅和向大模型“请教”,我决定采用 AST(Abstract Syntax Tree,抽象语法树)来智能地处理这个问题。AST 可以将代码解析成一个树状结构,其中的每个节点代表程序中的一种语法元素,比如表达式、语句、函数定义等。这个树的根是整个模块,而它的子节点可能是赋值语句、函数调用、条件判断等结构。 使用 AST 的主要目的就是精确地表达代码的语法和语义,忽略掉注释、空行等非实质性内容。而不是像之前那样去暴力添加print(). importast
classAutoPrintTransformer(ast.NodeTransformer): """ 自动为最后一个表达式添加 print() 的 AST 转换器 """ def__init__(self): super().__init__() self.modified =False
defvisit_Module(self, node): """ 处理模块节点,为最后一个表达式添加 print() """ self.generic_visit(node)
last_expr_index = -1 foriinrange(len(node.body) -1, -1, -1): stmt = node.body[i]
# 判断是否为 print 语句 is_print_stmt = ( isinstance(stmt, ast.Expr)and isinstance(stmt.value, ast.Call)and isinstance(stmt.value.func, ast.Name)andstmt.value.func.id=='print' )
is_docstring = ( isinstance(stmt, ast.Expr)and isinstance(stmt.value, ast.Constant)and isinstance(stmt.value.value,str)andi ==0 )
ifnotis_print_stmtandnotis_docstring: last_expr_index = i break
# 找到了需要添加 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_stmt self.modified =True
returnnode
这段代码的主要执行流程为: - 解析:使用 Python 内置的
ast库,通过ast.parse()将模型生成的代码字符串解析成一个 AST 对象。 - 遍历树:遍历 AST 的顶层语句节点列表
(node.body)。 - 定位最后一个表达式语句
- 检查是否漏掉了
print():判断这个表达式语句是否已经是print()调用,或者是否是一个字符串常量。 - 包装:如果它是一个需要打印结果的普通表达式,就将其 AST 节点包装在一个新的
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, traceback
classAutoPrintTransformer(ast.NodeTransformer): def__init__(self): self.has_modified =False
defvisit_Module(self, node): self.generic_visit(node) last_expr =None foriinrange(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) break iflast_expr: idx, expr = last_expr is_print = ( isinstance(expr.value, ast.Call)and isinstance(expr.value.func, ast.Name)and expr.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_node self.has_modified =True returnnode
deftransform_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 可正确执行。 2. Matplotlib 绘图结果无法展示
为了增强代码执行能力,我在构建 Docker 镜像时特意预制了 matplotlib 库,期望模型能够生成数据可视化图表。在测试中,模型确实能生成标准的绘图代码(例如绘制正弦波),并且也调用了 plt.show(),但是代码执行后,前端聊天界面却看不到任何图像,不过想想也对,这是当然的,因为 plt.show() 会尝试在图形用户界面(GUI)环境中打开一个窗口来显示图像。但在 Docker 沙箱中没有显示器或窗口系统,纯纯就是无头的环境 plt.show() 会静默失败或报错。 有人会觉得(这个人就是我):那能不能在 Prompt 中让模型保存图片呢?可这就出现了一个新的问题:即使模型改为调用 plt.savefig('plot.png') 将图像保存到文件,这个文件也只是存在于容器内,无法被外界直接访问,更何况一旦代码运行完毕,容器销毁之后数据就会丢失。 2.1 方案:动态注入代码后,遍历图片并提取
前置代码注入:执行模型代码前,在 header 区域注入一段作为补丁的代码,追踪所有创建的 Figure 对象,并设置 matplotlib 使用非交互式后端: importosimportuuidimportmatplotlibmatplotlib.use('Agg') # 使用非交互式后端importmatplotlib.pyplotasplt
fig_list = [] # 用于跟踪所有创建的图orig_figure = plt.figure # 保存原始的 plt.figure 方法
deftrack_figure(*args, **kwargs): """ 寻找生成的图形。 """ fig = orig_figure(*args, **kwargs) fig_list.append(fig) returnfig
plt.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] # 生成唯一 ID filename = os.path.join(output_dir,f"chart_{i}_{unique_id}.png") # 构建文件名 try: fig.tight_layout() exceptException: pass try: 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 开发过程中的局限性,为模型“擦好屁股”。
|