支持私有化部署
AI知识库

53AI知识库

学习大模型的前沿技术与行业应用场景


基于 Function Calling 构建代码执行工具时遇到的问题

发布日期:2025-05-18 13:01:46 浏览次数: 1540 作者:莫拉的博客
推荐语

探索AI模型如何执行复杂代码,实现智能化任务。

核心内容:
1. AI模型的高级能力:理解意图并执行复杂指令
2. 实现Function Calling机制,让模型调用工具和运行代码
3. 后端系统如何解析并执行模型请求的Python代码

杨芳贤
53A创始人/腾讯云(TVP)最具价值专家

在开发 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"]            ]        ]    ]];

模型会根据对话上下文,智能判断是否需要调用这些工具,并自动生成相应的参数请求,交由后端执行。

过程中遇到的几个问题和解决

1. 模型并非总会使用 print()

在初步测试 execute_python_code 函数时,我发现模型生成的代码经常执行失败。查看 Log 发现,问题在于模型生成的代码很多时候没有显式地使用 print() 函数来输出最终结果,模型也许把后端的 Python 环境当做了Jupyter Notebook 这种交互式环境了。例如,当我让模型计算 2+2 时,它可能会生成如下代码并请求执行:

2 + 2
## 或者result = 2+2result

在 Jupyter Notebook 等交互环境中,这么写确实没问题,可是我的后端是单纯的拉取 Python 镜像人为构建的 Docker 环境,Python 执行器运行这段代码之后,由于没有 print() 语句,标准输出就为空。这样一来,模型就无法接收到计算结果 "4",导致其认为代码执行失败。

1.1 失败的尝试:暴力拼接 print()


我最初的想法很简单:是不是可以在接收到的代码字符串末尾自动加上 print(...)?但很快意识到这种方法过于粗暴。并非所有代码执行都需要打印最后一个表达式的结果,例如代码可能只是定义函数、导入库等等,确实不需要 print()的存在。强行添加 print() 可能导致语法错误或非预期的行为。

1.2 第一种解决方案:利用 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, strand 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(),确保计算结果能够被正确捕获并返回。

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() 的方法进行处理。

import base64, ast, traceback
class AutoPrintTransformer(ast.NodeTransformer):    def __init__(self):        self.has_modified = False
    def visit_Module(self, node):        self.generic_visit(node)        last_expr = None        for i in range(len(node.body) - 1, -1, -1):            stmt = node.body[i]            if isinstance(stmt, ast.Expr) and not (                isinstance(stmt.value, ast.Constant) and isinstance(stmt.value.value, str)            ):                last_expr = (i, stmt)                break        if last_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'            )            if not is_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        return node
def transform_code(src):    try:        tree = ast.parse(src)        transformer = AutoPrintTransformer()        new_tree = transformer.visit(tree)        ast.fix_missing_locations(new_tree)        if transformer.has_modified:            exec(compile(new_tree, '<string>''exec'), globals())        else:            exec(src, globals())    except Exception as e:        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 使用非交互式后端:

import osimport uuidimport matplotlibmatplotlib.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 格式的图表:

![图表1](https://chatgpt.wljay.cn/sandbox/matplotlib/chart_xxxxx_12345.png)

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

如上图所示,用户提出让模型绘制一个正四棱台,模型理解了用户的意图并绘制出了一个美丽的图像,通过后端预处理逻辑被成功透传到公网目录中。

3. 总结

不管温度参数设定如何,由于 Python 语法的等效性,模型依然可能会不使用 print();或是 matplotlib 图像不显示。这时候就需要开发者在后端设计好相应的预处理、执行和后处理机制,就能有效地弥补 Agent 开发过程中的局限性,为模型“擦好屁股”。


53AI,企业落地大模型首选服务商

产品:场景落地咨询+大模型应用平台+行业解决方案

承诺:免费场景POC验证,效果验证后签署服务协议。零风险落地应用大模型,已交付160+中大型企业

联系我们

售前咨询
186 6662 7370
预约演示
185 8882 0121

微信扫码

添加专属顾问

回到顶部

加载中...

扫码咨询