在开发 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" => "在网络上搜索最新信息,获取权威内容 (Search the web for the latest information and authoritative content)",
"parameters" => [
"type" => "object",
"properties" => [
"query" => [
"type" => "string",
"description" => "搜索查询内容 (The search query)"
]
],
"required" => ["query"]
]
]
],
[
"type" => "function",
"function" => [
"name" => "execute_python_code",
"description" => "运行一段 Python 代码,并返回标准输出结果 (Execute a snippet of Python code and return the standard output)",
"parameters" => [
"type" => "object",
"properties" => [
"code" => [
"type" => "string",
"description" => "要执行的 Python 代码 (The Python code to execute, e.g., print(2+2))"
]
],
"required" => ["code"]
]
]
]
];
模型会根据对话上下文,智能判断是否需要调用这些工具,并自动生成相应的参数请求,交由后端执行。
过程中遇到的几个问题和解决
模型并非总会使用 print()
在初步测试 execute_python_code
函数时,我发现模型生成的代码经常执行失败。查看 Log 发现,问题在于模型生成的代码很多时候没有显式地使用 print()
函数来输出最终结果,模型也许把后端的 Python 环境当做了Jupyter Notebook 这种交互式环境了。例如,当我让模型计算 2+2 时,它可能会生成如下代码并请求执行:
2 + 2
## 或者
result = 2+2
result
在 Jupyter Notebook 等交互环境中,这么写确实没问题,可是我的后端是单纯的拉取 Python 镜像人为构建的 Docker 环境,Python 执行器运行这段代码之后,由于没有 print()
语句,标准输出就为空。这样一来,模型就无法接收到计算结果 "4",导致其认为代码执行失败。
失败的尝试:暴力拼接 print()
我最初的想法很简单:是不是可以在接收到的代码字符串末尾自动加上 print(...)
?但很快意识到这种方法过于粗暴。并非所有代码执行都需要打印最后一个表达式的结果,例如代码可能只是定义函数、导入库等等,确实不需要 print()
的存在。强行添加 print()
可能导致语法错误或非预期的行为。
解决方案:利用 AST 自动补全 print()
经过一番资料查阅和向大模型“请教”,我决定采用 AST(Abstract Syntax Tree,抽象语法树)来智能地处理这个问题。AST 可以将代码解析成一个树状结构,其中的每个节点代表程序中的一种语法元素,比如表达式、语句、函数定义等。这个树的根是整个模块,而它的子节点可能是赋值语句、函数调用、条件判断等结构。
使用 AST 的主要目的就是精确地表达代码的语法和语义,忽略掉注释、空行等非实质性内容。而不是像之前那样去暴力添加 print()
.
import ast
class AutoPrintTransformer(ast.NodeTransformer):
"""
自动为最后一个表达式添加 print() 的 AST 转换器
"""
def __init__(self):
super().__init__()
self.modified = False
def visit_Module(self, node):
"""
处理模块节点,为最后一个表达式添加 print()
"""
self.generic_visit(node)
last_expr_index = -1
for i in range(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) and stmt.value.func.id == 'print'
)
is_docstring = (
isinstance(stmt, ast.Expr) and
isinstance(stmt.value, ast.Constant) and
isinstance(stmt.value.value, str) and i == 0
)
if not is_print_stmt and not is_docstring:
last_expr_index = i
break
# 找到了需要添加 print 的表达式
if last_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
return node
这段代码的主要执行流程为:
- 解析:使用 Python 内置的
ast
库,通过ast.parse()
将模型生成的代码字符串解析成一个 AST 对象。 - 遍历树:遍历 AST 的顶层语句节点列表
(node.body)
。 - 定位最后一个表达式语句:找到最后一个类型为
ast.Expr
的语句节点。 - 检查是否漏掉了
print()
:判断这个表达式语句是否已经是print()
调用,或者是否是一个字符串常量。 - 包装:如果它是一个需要打印结果的普通表达式,就将其 AST 节点包装在一个新的
print()
函数调用节点中,并替换掉原来的表达式语句节点。 - 生成新代码并执行:使用
ast.unparse()
将修改后的 AST 转换回 Python 代码字符串,或者直接编译并执行。
通过这种方式,即使模型没有写 print()
,我们的后端也能找到所有漏掉的位置并动态补齐 print()
,确保计算结果能够被正确捕获并返回。
Matplotlib 绘图结果无法展示
为了增强代码执行能力,我在构建 Docker 镜像时特意预制了 matplotlib 库,期望模型能够生成数据可视化图表。在测试中,模型确实能生成标准的绘图代码(例如绘制正弦波),并且也调用了 plt.show()
,但是代码执行后,前端聊天界面却看不到任何图像,不过想想也对,这是当然的,因为 plt.show()
会尝试在图形用户界面(GUI)环境中打开一个窗口来显示图像。但在 Docker 沙箱中没有显示器或窗口系统,纯纯就是无头的环境 plt.show()
会静默失败或报错。
有人会觉得(这个人就是我)那能不能在 Prompt 中让模型保存图片,可这就出现了一个新的问题:即使模型改为调用 plt.savefig('plot.png')
将图像保存到文件,这个文件也只是存在于容器内,无法被外界直接访问,更何况一旦代码运行完毕,容器销毁之后数据就会丢失。
最终方案:动态注入代码后,遍历图片并提取
前置代码注入:执行模型代码前,在 header 区域注入一段作为补丁的代码,追踪所有创建的 Figure
对象,并设置 matplotlib 使用非交互式后端:
import os
import uuid
import matplotlib
matplotlib.use('Agg') # 使用非交互式后端
import matplotlib.pyplot as plt
fig_list = [] # 用于跟踪所有创建的图
orig_figure = plt.figure # 保存原始的 plt.figure 方法
def track_figure(*args, **kwargs):
"""
寻找生成的图形。
"""
fig = orig_figure(*args, **kwargs)
fig_list.append(fig)
return fig
plt.figure = track_figure
- 后置代码注入:在代码执行结束后,遍历所有图像对象,将它们保存为 PNG 文件,透传到本地目录中,并将路径转换为公网可访问的图像链接:
...
saved_paths = []
if 'fig_list' in globals() and fig_list:
output_dir = "/output" # 输出目录
os.makedirs(output_dir, exist_ok=True)
for i, fig in enumerate(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()
except Exception:
pass
try:
fig.savefig(filename, dpi=100, bbox_inches='tight') # 保存图像
saved_paths.append(filename) # 添加路径到列表
except Exception as e:
print("出错!!")
finally:
plt.close(fig) # 关闭图形
if saved_paths:
for img_path in saved_paths:
print(f"###IMAGE_FILE_PATH###{img_path}")
else:
print("没有图表。")
plt.close('all'
- 模型处理逻辑:并使用着重符号
###IMAGE_FILE_PATH###
来提示模型当前位置有图片,使用让模型可以理解的自然语言来输出 Markdown 格式的图表:

一系列处理后,模型所绘制的图像就能在前端被展示给用户了。

如上图所示,用户提出让模型绘制一个正四棱台,模型理解了用户的意图并绘制出了一个美丽的图像,通过后端预处理逻辑被成功透传到公网目录中。
总结
不管温度参数设定如何,由于 Python 语法的等效性,模型依然可能会不使用 print()
;或是 matplotlib 图像不显示。这时候就需要开发者在后端设计好相应的预处理、执行和后处理机制,就能有效地弥补 Agent 开发过程中的局限性,为模型“擦好屁股”。
发表回复