ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

教学智能体函数绘图实战:从模型随机生成到确定性代码执行

教学智能体函数绘图实战:从模型随机生成到确定性代码执行 接手教学智能体这个项目的时候我压根没把“画函数图”这个需求当回事。无非就是让学生输入一个函数返回一张图嘛在我原本的设想里这撑死就是一个下午的活。结果真做起来才发现这里面的坑深得离谱。从表达式解析报错到图表中文全变方块再到同一个问题每次画出来的图都不一样足足折腾了快两天才把方案彻底跑稳。今天这篇就把完整的折腾过程、踩过的坑和最终定型的做法写下来给正在做智能体、尤其是做教学类智能体的朋友一个参考。1. 需求没那么简单教学智能体要的不只是一张图1.1 大模型会说图但不会画图学生问“老师帮我画一下 y x² - 2x 3 的图像”这事对大模型来说其实很尴尬。你让它用文字描述这个函数长什么样它能说得头头是道开口向上、对称轴 x1、顶点坐标是 (1, 2)。但你要它真给你一张图它做不到。为什么因为大模型的强项是语义理解和文本生成不是数值计算和精确渲染。你可以让它在文本里近似描述一个图形但它不具备“画布”的概念。即便现在很多多模态模型能生成图片那也是“生成一张长得像函数图的图”而不是“根据数学表达式精确渲染出正确的曲线”。我实测过几个声称支持绘图的多模态模型让它们画同一条抛物线有的顶点位置明显不对有的甚至是随手画的波浪线这在教学场景里是绝对不行的——学生是要拿这张图去对答案的图像差一个像素就可能造成误导。所以教学智能体里的“画函数图”从根上就不能指望大模型直接“画”必须把它拆成“模型理解用户意图 程序执行绘图”两步。谁负责思考谁负责计算从一开始就要分清楚。1.2 三条技术路线的性价比对比在动手写代码之前我把能想到的实现路线全部列了一遍大概有下面三条路线开发成本覆盖能力聊天窗口展示稳定性静态图片库 检索匹配低很差好极高第三方图表服务 API中中差中本地/云端执行代码绘图中高高好中高先说第一种静态图片库。这个思路是预先渲染好所有常见函数图像比如一次函数、二次函数、反比例函数、三角函数等等然后让大模型根据用户问题去匹配对应的图片。听着很省事但覆盖能力实在堪忧。教学场景里学生问的问题千变万化“带参数的函数”、“两个函数的交点”、“自定义定义域的函数”每一种变化都得预生成一张图这基本上是不可能的。我自己试了一个下午就放弃了因为光是二次函数 y ax² bx ca、b、c 稍微变几个值图像就完全不同静态库根本无法穷举。第二种调用第三方图表服务。丹斯莫这类在线图表工具确实画得好但接 API 有几个麻烦一是函数表达式要转换成人家规定的语法格式出错了很难排查二是返回结果往往不是一张可以直接塞进聊天窗口的 PNG 图片而是一段参数拼接的 URL 或者交互组件在消息流里展示很别扭。而且每次画图都要发起外部网络请求链路一旦不稳定教学现场就卡住了。第三种也是我最终选的路线——在可控环境里执行 Python 代码用 matplotlib 这类库把函数图像渲染出来。它的优势很直接完全可控、可定制样式想怎么调就怎么调渲染结果是标准的图片文件聊天窗口直接就能展示而且现代智能体平台大多提供了代码节点或工具调用机制落地不算难。缺点也有就是你必须解决两件事代码能不能安全执行以及代码能不能稳定生成。这两个问题就是后面两天折腾的核心。2. 为什么代码执行成了唯一靠谱的方案2.1 大模型直接画图的三重限制我一开始也抱着侥幸心理想看看有没有办法让大模型输出一个“自包含”的图像描述比如 SVG 或者 MathML然后前端渲染。试了一圈结论是这条路比想象中更不好走。第一重限制是数值精度。SVG 是矢量格式理论上可以描述一条精确的抛物线路径但那是建立在“模型知道这条曲线的每个关键点坐标”的前提下。实际上大模型让我手写路径控制点它给的坐标往往只能让曲线“看起来像那么回事”真要放大检查顶点位置、曲率、渐近线全是偏的。数学函数图像最讲究的就是数值关系曲线偏移几个像素函数性质就变了。第二重限制是连续性问题。函数图像往往涉及大量密集采样点尤其是处理快速变化的区域。让模型生成几十个坐标点本身就很吃力更别说几十万个点。你不可能靠“生成点列表”这种模式画出一张光滑的 y sin(x) 图像模型的上下文窗口和输出长度都撑不住。第三重限制是规则遵守。模型就算承诺“我输出 SVG”在长输出过程中也容易漂移一会儿忘记加命名空间一会儿把颜色值写错一会儿在路径里混入非法字符。这些错误在聊天场景里还凑合能忍在精确绘图场景里就是硬伤。所以结论很明确想让智能体稳定地画出函数图必须把“绘图”这件事从大模型的能力范围里拿出来做成一个确定性的程序能力。2.2 代码执行架构把“思考”和“计算”分开代码执行方案的核心思想简单说就是“各司其职”。大模型只做自己擅长的事理解用户的自然语言请求判断用户想问的函数是什么、有没有特殊的定义域要求、需不需要标注某些特殊点。然后把这些信息转化成结构化的绘图请求。执行引擎做自己不擅长但很擅长的事拿到这个结构化请求后解析数学表达式、在定义域内采样、处理奇点和边界、调用绘图库渲染图片、返回图片链接。这些步骤全部由确定性代码完成每一步都可复现、可调试、可校验。我当时用的是主流智能体平台的代码节点。说白了平台提供一个沙箱环境让你写 Python 代码然后把这个能力封装成一个工具。用户问函数图智能体先判断该调用哪个工具再把关键参数填进去代码节点负责跑出图片。整个链路听起来很简单但实际踩坑的过程一点不简单。真正帮我把思路拽回来的是后来一个习惯凡是能被写成确定性代码的逻辑绝不让大模型自由发挥。模型只出参数代码来出结果。这个原则贯穿了后续所有设计。3. 两天里真实踩过的坑我按时间顺序记下来了3.1 第一轮翻车表达式解析和数学记号冲突第一个让我心态崩掉的坑是表达式的解析。学生写“y x^2”是天经地义的数学记法但 Python 根本不认识这个写法因为^在 Python 里是按位异或运算符。于是智能体第一次执行绘图代码的时候直接抛了一个 SyntaxError学生看到的是“抱歉我暂时无法生成图像”。一开始我还以为是模型生成的代码不稳定就多调了几次结果发现就算模型自己把^换成**也还会遇到新的问题。比如学生写“y 2x 1”Python 里必须写成2 * x 1那个省略掉的乘号模型偶尔会补上偶尔就意识不到。还有学生写“y sin(2x)”如果直接扔给math.sin连隐式乘法带函数前缀一起处理麻烦更大。排查的过程其实有点绕。我先打印出了模型生成的 Python 代码发现它自己在做各种字符串替换和拼装但规则总是覆盖不全。后来我意识到问题根源出在架构上你让模型自己处理数学记号和 Python 语法的转换等于让它做一件容错率极低的事。正确的解法是把这个转换逻辑写死在代码里用规则和解析库去兜底。最终我引入了sympy这个符号计算库配合几个正则替换规则专门处理数学表达式的规范化。比如把^换成**用正则匹配数字后直接跟字母或括号的情况补上乘号。经过规范化之后无论是x^2、2x还是sin(2x)都能被正确解析成 Python 可执行的表达式。3.2 第二轮翻车中文字体全变方块表达式问题解决了之后图像终于能画出来了。结果第二张图就让人哭笑不得——函数曲线是对的但图例、标题全部变成了一个个小方块。说白了就是 matplotlib 默认字体不支持中文中文全部渲染成了“□□□”。这个坑最折磨人的地方在于我在本地调试的时候完全正常因为本地系统里装了中文字体但一旦部署到平台的代码节点里运行环境是最小化的 Linux 容器系统里压根没有 SimHei、微软雅黑这类中文字体。所以图表里的“二次函数”四个字全变成了方框。我第一次尝试是直接在代码里指定plt.rcParams[font.sans-serif] [SimHei]但代码节点环境里没有这个字体指定了也白搭。后来我查了容器的系统字体目录发现里面只有一个特别朴素的字体而我又没有权限随便往系统里塞字体文件。最后采用了一个务实的方案图片内部尽量不出现中文标题和图例全用英文或者纯数学符号。比如标题就写y x^2 - 2*x 3图例写f(x)这些英文字符在任何 Linux 字体下都能正常显示。至于中文解释放在图片前后的大模型回复文本里反正模型生成文字又不费劲。这里顺手还解决了一个跟中文字体相关的小问题负号的显示。matplotlib 默认的字体里负号有时候会显示成一条横杠甚至乱码需要设置plt.rcParams[axes.unicode_minus] False才能保证坐标轴上的负数正常显示。这个问题如果不注意画出来的图特别掉价。3.3 第三轮翻车坐标轴范围让图像完全走样字体问题解决了第三轮打击来自坐标轴范围。我一开始的绘图脚本非常耿直拿到函数表达式从 x -10 到 x 10 采样然后直接画。遇到 y x² 这种函数没问题但遇到 y 1/x 这种在 x0 处有奇异点的函数直接在 x0 附近取到无穷大画出来的图像包含一条几乎垂直的直线把整个图都带偏了。还有 y tan(x)本来渐近线之间那些规律性的曲线会因为绘制范围太宽而糊成一团图像完全没法看。这个问题的本质是数学函数的数值特征和线性采样之间的矛盾。你不知道用户会输入什么函数也不知道它的值域宽到什么程度如果让模型自己决定坐标范围它给出的数值经常和函数实际形态对不上。我的处理方式分成两步。第一步在做数值计算的时候用numpy算出一整组采样点然后把超过阈值比如 |y| 10⁶的点直接记为 NaN这样这些点就不会被画出来也就不会产生那种吓人的竖直断层线。第二步在计算完有效点之后根据实际 y 值的最小值和最大值自动微调纵轴范围避免图像离坐标轴太远或者太挤。这套逻辑用在 1/x、tan(x) 这类带有奇异点的函数上效果立竿见影。顺带还处理了一个相关需求有时候用户会指定“只看 x 从 0 到 5”的范围。如果模型都按默认的 -10 到 10 画用户会觉得智能体根本没听懂。所以我把 x 的范围也做成了参数模型可以从对话里提取用户指定的定义域提不出来才用默认值。3.4 第四轮翻车模型自由生成代码的不确定性前三轮坑虽然费时间但至少思路清晰。真正让我差点推倒重来的是模型的“自由发挥”。最初我的方案是大模型根据用户的函数请求自行生成一段完整的 matplotlib 绘图代码然后交给执行节点跑。你猜怎么着同一个问题连问三次模型给出了三种完全不同风格的代码。第一次是plt.plot(x, y)第二次是ax.plot(x, y)第三次干脆忘了保存图片直接调用了plt.show()。在无图形界面的服务器环境里plt.show()什么都不返回图像文件根本没生成智能体对外只能说一句“绘图失败”。更离谱的是有一次它在代码里把import numpy as np写成了import nupy as np执行直接崩掉。这种错误在本地调试时完全复现不出来因为它每次生成代码都是重新采样的。到那个时候我终于做了一个决定不再让模型写代码了。这个决定的背后是对大模型能力边界的重新认识。模型生成代码的能力确实强但它不具备“稳定输出同一套代码风格”的承诺。在交互式编程场景里这种灵活性是优点但在生产链路里这种随机性会变成事故源。我需要的是稳定同一个函数今天画是这样明天画还是这样。4. 最终方案只让模型填空不让模型写代码4.1 工具参数协议设计第四轮翻车之后我把整个实现思路推倒重来最后定下来的方案核心就一句话模型只输出一套结构化的绘图参数绘图逻辑全部由固定代码完成。这套参数我设计成了 JSON 格式长这样{ expression: x**2 - 2*x 3, x_min: -5, x_max: 5, grid: true, title: y x^2 - 2x 3, show_axis: true }字段解释一下。expression是经过规范化处理的函数表达式必须能被sympy正确解析x_min和x_max是横轴范围用户没指定时用默认值 -10 到 10grid控制是否显示网格线title只是展示用的标题可以是纯数学写法show_axis控制是否显示坐标轴参考线。这里最关键的是expression这个字段。模型不需要自己拼 Python 代码它只要从用户的自然语言里把“数学表达式”这个要素提取出来再转成规范格式就行。比如用户说“帮我画二次函数 yx²-2x3”模型只需要输出上面那个 JSON。我一开始还担心模型会把表达式里的中文或者特殊符号带进来实际测试发现只要在提示词里明确“只输出 JSON 对象不要输出任何其他内容”绝大多数情况下模型都能很好地按要求填空。就算偶尔输出格式非法也可以做一层容错解析而不是直接让整个链路崩溃。4.2 固定绘图引擎的核心实现参数协议定了剩下的工作就是写好那个固定的绘图引擎。下面是我简化后的核心代码算是整个方案最能抄作业的部分import matplotlib matplotlib.use(Agg) # 无图形界面环境下必须使用非交互式后端 import matplotlib.pyplot as plt import numpy as np import sympy as sp import re def normalize_expression(expr: str) - str: 数学表达式规范化把数学记号转成 Python 可执行的形式。 expr expr.strip() expr expr.replace(^, **) # 处理隐式乘法数字后直接跟字母/括号或者右括号后直接跟数字/字母 expr re.sub(r(\d)([a-zA-Z(]), r\1*\2, expr) expr re.sub(r([a-zA-Z)])(\d), r\1*\2, expr) return expr def plot_function(expr: str, x_min: float, x_max: float, title: str, grid: bool True, show_axis: bool True, output_path: str function_plot.png): expr normalize_expression(expr) x sp.Symbol(x) try: f sp.lambdify(x, sp.sympify(expr), modules[numpy]) except Exception as e: raise ValueError(f表达式解析失败: {e}) xs np.linspace(x_min, x_max, 2000) ys f(xs) # 处理奇异点把绝对值过大的值置为 NaN避免画出竖直断线 ys np.where(np.abs(ys) 1e6, np.nan, ys) # 如果全为 NaN则说明范围内没有有效点给个默认范围避免空图 if not np.isfinite(ys).any(): ys np.zeros_like(xs) plt.figure(figsize(8, 6)) plt.plot(xs, ys, linewidth2.5) if show_axis: # 画出 x 轴和 y 轴的参考线 plt.axhline(0, colorblack, linewidth0.8) plt.axvline(0, colorblack, linewidth0.8) if grid: plt.grid(True, linestyle--, alpha0.6) plt.title(title) # 保证负号正常显示 plt.rcParams[axes.unicode_minus] False # 自动调整纵轴范围只用有效点来算 finite_ys ys[np.isfinite(ys)] if len(finite_ys) 0: y_min, y_max np.min(finite_ys), np.max(finite_ys) margin (y_max - y_min) * 0.1 if y_max ! y_min else 1.0 plt.ylim(y_min - margin, y_max margin) plt.savefig(output_path, dpi150, bbox_inchestight) plt.close() return output_path这段代码里有四个地方是整个方案稳定性的关键。前面已经反复说的规范化函数是所有解析问题的总闸门。re.sub处理隐式乘法的时候用正则区分“数字后跟字母或左括号”和“右括号后跟数字或字母”。这个写法简单测试下来能覆盖学生输入里九成以上的写法。那个sp.lambdify(x, sp.sympify(expr), modules[numpy])是核心中的核心。sympify把表达式文本转成 SymPy 的符号表达式对象lambdify再把符号表达式变成一个可调用的 NumPy 函数。这个组合的好处是无论表达式里带不带三角函数、对数函数它都能自动映射到 NumPy 对应的函数实现不需要我手动写一大串if判断。奇异点处理也非常重要。学生输入y1/x的时候如果不做掩膜x0附近会产生一个无穷大的值图像就是一条凌乱的竖直线。np.where(np.abs(ys) 1e6, np.nan, ys)这行代码会把超出合理范围的点全部丢掉曲线在奇异点附近自然断开这才是数学上正确的画法。自动纵轴范围这段代码是海量调试之后加进去的。最开始图的上下边缘经常贴住曲线或者顶部空一大片学生看着很别扭。有了finite_ys这个变量专门过滤掉 NaN 点再留出 10% 的边距图的视觉效果稳定了很多。4.3 安全性与执行性能的注意点让智能体执行代码安全性一定是逃不开的问题。我虽然把生成完整代码的权力收了回来但expression本质上还是由模型输出的和恶意输入也只是一线之隔。所以执行环境必须做安全兜底。如果是在 Coze、Dify 这类平台内置的代码节点里跑平台本身已经做了沙箱隔离我只补充了两件事一是对表达式字符串的长度做限制超过 200 个字符直接拒绝防止有人用超长表达式消耗计算资源二是在sympify之前加一层简单的关键字检查拦截import、open、exec这类明显的危险操作。如果是自己部署服务一定要把绘图进程关在 Docker 容器之类的最小化环境里面只给它必要的依赖库不给它访问外部网络的能力。毕竟教学场景面对的可能是未成年人安全上多谨慎都不为过。性能方面2000 个采样点对 matplotlib 来说是很轻的负载单张图渲染耗时基本在几百毫秒以内。真正占用时间的通常是大模型生成 JSON 参数的过程但那本身就不是这段代码能优化的交给智能体平台去处理就好。5. 复盘与建议给教学类智能体开发的几条实在经验5.1 对“模型随机性”的再认识能少写一行代码就少写这次折腾最大的教训不是哪一个技术点而是对模型能力边界的一次重新校准。模型很强但它的强是“生成内容”的强不是“保证输出确定性”的强。尤其是当你需要连续几十次、几百次调用都保持输出格式一致时模型的自由生成能力就成了风险源头。说白了让大模型写 Python 代码它能写好但它写一百次可能有一百种风格。在教学智能体里我们需要的是稳定不是创意。凡是能被规则定义清楚的逻辑一律从模型能力里拿出来用代码固定住。模型只处理语义理解层面的事也就是“用户到底想画什么、用户有没有提特殊要求”。输出端尽量收敛成填空题最好收敛到只有一个 JSON 对象。这次演示的函数图功能已经是把“模型参与度”压缩到了最低模型输入是自然语言输出是 JSON 参数中间的表达式规范化、曲线采样、坐标范围计算、图片渲染全部是确定性代码。这样设计之后同一个函数无论问多少遍画出来的图都是一样的。5.2 给教学场景智能体的三条避坑建议第一条建议是做好函数表达式的容错。教学场景里学生输入的表达式五花八门有从教材抄的有从搜索框复制的有自己随手打的。对输入做过度的严格要求是不可能的只能靠解析层去兜底。正则替换加符号计算库是目前我试过最有效的组合。第二条建议是图片之外一定要保留原始数据。我自己踩过一次学生问“这个函数和 yx 的交点在哪里”智能体画了两条曲线学生盯着图看了半天还是不知道具体坐标。后来我在工具设计时加了“附加计算”的字段函数图生成之后智能体可以额外调用一段求解交点的代码把坐标值返回在文字里。图是给人看的直观信息具体数值才是教学里要落地的答案。第三条建议是记录每一次绘图请求的完整日志。不要小看这个动作。以前没有日志的时候学生反馈“智能体画的图好像不对”我只能干瞪眼因为没有证据去复现他到底输入了什么、模型当时提取了什么参数。加了日志之后每次请求的原始文本、规范化后的表达式、最终渲染的图片路径都记录下来大部分“图不对”的问题肉眼一看日志就能定位到底是模型提取错了参数还是绘图代码本身做了错误的数学假设。5.3 后续还能怎么扩展函数图这个功能跑稳之后我又想到了几个自然而然的扩展方向。第一个是图像叠加。现在只是画单个函数但教学里经常需要对比函数图像比如“画一下 yx² 和 yx³ 的图像对比”。这个只需要让参数协议里支持一个表达式数组固定绘图引擎去循环绘制模型那边要做的只是把多个表达式都提取出来难度不大。第二个是交互式坐标点标注。比如用户问“这个函数的顶点在哪里”智能体可以额外返回一个顶点坐标然后在图上用红点标出来。这个功能需要绘图引擎在画完主曲线之后根据传入的特殊点数组追加散点图。从模型角度说只是多输出一个special_points字段的事。第三个是动图支持。像“感受一下 y ax² 当 a 从 0.1 变到 2 时图像怎么变化”这种需求静态的单一图片已经不够了得输出一组图片甚至 GIF 动画。这个让代码生成多帧图片再合成就行模型的职责依然是输出参数列表。好这篇折腾记录就写到这里。其实最想分享给各位的不是那几百行 Python 代码而是“所有教学功能都要以稳定为前提”这个贯穿始终的原则。学生看到的每一次结果都是在给智能体的能力打分交错一次图可能就失去一份信任。先把确定的部分全部用代码锁死再让模型去处理它真正擅长的语言理解这才是教学智能体能越用越好用的根基。
返回列表