“画函数图”这件事,看着只是给教学智能体加一个普通功能,真做起来才发现,它把“LLM生成答案”和“确定性计算渲染”两条路线的问题全暴露了一遍。我花了两天时间,试过直接让模型写代码、生成SVG、甚至调用画图工具,最后踩完坑发现,还是得把“精确绘图”从智能体的自由发挥里拆出来,交给一套固定逻辑去执行。这篇文章把完整思路、核心代码、测试案例和排查记录都整理出来,给正在做智能体,尤其是教育类智能体的朋友一个参考。
1. 教学智能体为什么画不好函数图
1.1 需求复盘:学生和老师到底要什么
先说清楚场景。我做的这个教学智能体,面向高中数学,学生会在对话框里直接输入类似“画一下 y=x^2 的图像”“y=sin(x) 在 -2π 到 2π 的曲线”“帮我看看 y=1/(x-1) 的渐近线”。它不能只回一句“这是一个开口向上的抛物线”,而是必须给出坐标轴清晰、刻度合理、函数曲线准确的图。老师批改作业、学生预习复习,都会拿这张图去对照解析式。
这个需求听起来简单,但拆开看其实有三层:第一,要从自然语言里准确提取数学表达式和取值范围;第二,要用确定性的数学计算在指定区间内算出足够多的点;第三,要把这些点渲染成一张可读性强、带坐标轴和刻度的图。前一层LLM能做,后面两层纯靠模型输出就不靠谱。真正让我决定重写的,是连续几次测试:我让智能体画 y=x^2,它用文字描述了半天,然后给了一段自己编的SVG代码,结果是图形左右不对称、顶点不在原点,坐标轴刻度间距也完全对不上。
1.2 试过的三种方案和踩坑记录
第一天我试了三个方向,都有明显问题。
第一个方向是“纯提示词驱动”,让LLM直接生成SVG或HTML代码。这个方法的好处是零开发,坏处是模型对坐标系的理解非常不稳定。SVG的坐标原点在左上角,而数学坐标的原点在中心,Y轴方向还是反的。模型偶尔能写对,但一旦函数稍微复杂,比如带分数、带三角函数,它就会为了凑代码而牺牲数学正确性,画出来的图像和真实曲线有肉眼可见的偏差。
第二个方向是让智能体调用通用的AI画图工具,生成一张“示意图”。这个方向对于函数图来说是灾难,因为画图大模型擅长画风景、人物,不理解抛物线的精确顶点,也不理解渐近线。生成出来的图往往是歪歪扭扭的曲线,附带根本不存在的坐标刻度。作为教学用途,这种图不仅没帮助,还会误导学生。
第三个方向是让LLM写完完整代码后丢给代码解释器执行。当时我用的是平台自带的代码节点,能跑Python,但很快发现两个坑:一是模型生成的代码经常包含不存在的库、错误的函数名;二是即使代码能跑,输出图片的URL和格式也不可控。好不容易跑通一个y=x^2,换到y=log(x)又崩了,因为对数函数的定义域是正数,模型生成的采样点没有做过滤。
当天晚上我做了个判断:热搜里那些“智能体开发”“智能体工作流”的讨论,本质都在强调同一件事——智能体不是“模型什么都能干”,而是“模型负责理解,工具负责执行”。函数图这种高精度任务,应该走后者。
2. 方案选型:把“画图”从提示词里拆出来
2.1 为什么选择“意图识别+代码节点”
最终我采用的方案是:智能体入口负责意图识别和参数提取,代码节点负责解析表达式、计算点集、生成SVG图。用术语说,这叫“LLM做规划,代码做执行”。
这么做有几个直接好处。首先是准确率可控。数学计算和坐标变换全部由代码完成,只要代码逻辑没问题,y=x^2 画出来必然是正确的抛物线,不会像LLM那样自由发挥。其次是调试成本低。出问题时我可以直接看代码节点里的日志,是表达式解析失败、还是除零溢出、还是采样点数量不够,每一步都有明确报错,而不是对着模型生成的一大段SVG猜哪里写错了。
第三个好处是方便扩展。今天我能画 y=f(x) 的显式函数,明天想在同一个节点里加求零点、求极值、标注特殊点,只需要在代码里追加几个函数,不需要重新训练模型。对于教学智能体来说,这种扩展需求非常高频:学生画完图往往马上会问“顶点在哪”“跟x轴交点是多少”,如果这些算法逻辑已经在代码节点里,返回直接拼进回答就好。
我用的平台是Coze(扣子),因为它有现成的工作流编排和代码节点;后端逻辑换个支持Python代码节点的智能体框架也完全一样,核心思想不绑定平台。
2.2 数学表达式解析的边界设计
这个环节是整个方案的灵魂,也是最容易出安全问题的地方。智能体收到文本“y=x^2”后,第一个代码节点要做的就是把它变成Python能计算的数学表达式。很多人第一反应是直接eval(),但绝对不能这么干。万一学生输入“y=import('os').popen('ls').read()”,你的代码节点就成了一个命令执行入口。
我的做法是分三步:
- 统一格式:把用户可能输入的“^”替换成“**”,把中文的“×”“÷”“−”替换成
* / -,去掉多余空格。 - 白名单校验:用Python的
ast模块把表达式解析成语法树,然后遍历所有节点,只允许Expression、BinOp、UnaryOp、Call、Name、Constant等基础节点,函数名只放行sin、cos、tan、sqrt、log、exp、abs这些风险低的数学函数。 - 参数范围限制:默认区间和自定义区间都做边界检查,比如X轴范围最多允许
[-1000, 1000],Y轴显示范围如果由用户指定也做同样限制,避免恶意参数导致采样数量爆炸。
如果你用的是JavaScript代码节点,就用Function构造器加白名单校验,原理一样。关键不是用什么语言,而是“默认不信任输入,只放行明确允许的内容”。
3. 动手实现:工作流与代码节点搭建
3.1 智能体入口与提示词设计
第一步是写系统提示词。我经历过一个误区,就是试图在提示词里强行教模型“怎么画函数图”。后来想明白了,模型不需要懂SVG,它只需要负责两件事:判断用户是不是要画函数图,以及把用户原话转成结构化参数。
提示词里我明确要求模型按固定JSON格式输出,例如:
{ "function": "x**2 - 2*x - 3", "x_min": -5, "x_max": 5, "y_min": "auto", "y_max": "auto" }同时给模型几个Few-shot示例。比如用户输入“画一下y=x^2-2x-3,区间[-3,5]”,输出就应该是上面这个JSON;用户只输入“y=sin(x)”,区间缺省时模型要默认填-6.28到6.28,因为三角函数周期与π有关。这一步不用模型做数学计算,但要做“范围感知”:一次函数给个对称区间、二次函数能包住顶点、三角函数至少一个周期,这些经验值可以写进提示词,让模型生成的采样范围更合理。
3.2 代码节点:从表达式到SVG
接下来是核心代码。我用Coze的Python代码节点,输入参数就是上面JSON里的字段,输出是一个包含SVG图片URL的字符串。下面这段代码是我调试后简化过的核心逻辑:
import ast import math import base64 import json # 白名单函数 SAFE_FUNCS = { "sin": math.sin, "cos": math.cos, "tan": math.tan, "sqrt": math.sqrt, "log": math.log, "log10": math.log10, "exp": math.exp, "abs": abs, "pi": math.pi, "e": math.e } def safe_eval(expr, x_value): """只允许带白名单函数的数学表达式""" node = ast.parse(expr, mode="eval") for n in ast.walk(node): if isinstance(n, ast.Name): if n.id not in SAFE_FUNCS: raise ValueError(f"非法标识符: {n.id}") if isinstance(n, ast.Call): if not isinstance(n.func, ast.Name) or n.func.id not in SAFE_FUNCS: raise ValueError(f"非法函数调用") # 把x和常量注入求值环境 ns = SAFE_FUNCS.copy() ns["x"] = x_value return eval(compile(node, "<string>", "eval"), {"__builtins__": {}}, ns) def generate_function_svg(expr, x_min, x_max, y_min=None, y_max=None): # 采样:步长取区间长度的0.5%,既保证平滑又不至于太慢 step = (x_max - x_min) / 400 xs, ys = [], [] skipped = False for i in range(401): xv = x_min + i * step try: yv = safe_eval(expr, xv) if not math.isfinite(yv): skipped = True continue # 如果和上一个点差距过大,说明可能跨过了间断点,放弃连线 if xs and abs(yv - ys[-1]) > (abs(ys[-1]) * 50 + 50): skipped = True continue xs.append(xv) ys.append(yv) skipped = False except Exception: skipped = True continue if not xs: return "### 图像生成失败\n请检查函数表达式和取值范围。" # Y轴范围自适应 auto_min, auto_max = min(ys), max(ys) if y_min is None or y_max is None: y_min = auto_min - (auto_max - auto_min) * 0.1 y_max = auto_max + (auto_max - auto_min) * 0.1 if y_min == y_max: y_min -= 1 y_max += 1 # SVG画布,宽度600,高度480,四周留出边距 W, H, M = 600, 480, 40 plot_w = W - 2 * M plot_h = H - 2 * M def to_svg_x(x): return M + (x - x_min) / (x_max - x_min) * plot_w def to_svg_y(y): return M + (y_max - y) / (y_max - y_min) * plot_h parts = [] parts.append(f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}">') parts.append(f'<rect width="{W}" height="{H}" fill="#ffffff"/>') # 网格线 parts.append('<g stroke="#e0e0e0" stroke-width="1">') for i in range(5): gx = M + i / 4 * plot_w parts.append(f'<line x1="{gx}" y1="{M}" x2="{gx}" y2="{M+plot_h}"/>') for i in range(5): gy = M + i / 4 * plot_h parts.append(f'<line x1="{M}" y1="{gy}" x2="{M+plot_w}" y2="{gy}"/>') parts.append('</g>') # 坐标轴 x_axis_y = to_svg_y(0) if y_min < 0 < y_max else M + plot_h y_axis_x = to_svg_x(0) if x_min < 0 < x_max else M parts.append(f'<line x1="{M}" y1="{x_axis_y}" x2="{M+plot_w}" y2="{x_axis_y}" stroke="#333333" stroke-width="1.5"/>') parts.append(f'<line x1="{y_axis_x}" y1="{M}" x2="{y_axis_x}" y2="{M+plot_h}" stroke="#333333" stroke-width="1.5"/>') # 刻度标签 parts.append(f'<text x="{M+plot_w-10}" y="{x_axis_y-8}" font-size="14" fill="#333333">x</text>') parts.append(f'<text x="{y_axis_x+8}" y="{M+12}" font-size="14" fill="#333333">y</text>') # 函数曲线 points = [] for i in range(len(xs)): points.append(f"{to_svg_x(xs[i]):.2f},{to_svg_y(ys[i]):.2f}") parts.append(f'<polyline points="{" ".join(points)}" fill="none" stroke="#1f77b4" stroke-width="2.5"/>') parts.append('</svg>') svg_text = "".join(parts) b64 = base64.b64encode(svg_text.encode("utf-8")).decode("ascii") return f"### 函数图像\n"这段代码有几个关键设计:用ast做白名单校验,能挡掉大部分非法表达式;采样点固定为401个,对大多数教学函数足够,又能避开平台执行超时;画SVG时先把数学坐标转换成像素坐标,重点处理了Y轴翻转和坐标轴位置。实际调试中最容易出问题的就是Y轴方向,SVG的Y坐标向下,如果忘记把(y_max - y)映射上去,图会上下颠倒,第一版我就犯了这个问题。
3.3 把图片返回给用户
代码节点最终返回的是data:image/svg+xml;base64,开头的Markdown图片。这么做有两个原因:一是SVG是矢量图,放大缩小都清晰,适合教学场景投屏和打印;二是它不像PNG那样需要临时存储和下载地址,生成后直接嵌进聊天消息,链路最短。
但这里有一个兼容性问题:有些聊天客户端对data:协议的图片链接支持不好。我的处理方式是,智能体工作流的最后接一个“生成结论”的大模型节点,让它根据代码节点返回的图片链接,再配一段不超过60字的教学讲解。学生看到的就是一张图和一句话解释,体验比较完整。
如果是在Web端自建应用,还可以让代码节点直接返回纯SVG字符串,前端用dangerouslySetInnerHTML或者v-html渲染,交互会更灵活。但放在通用智能体平台里,Markdown图片链接是最稳妥的。
4. 实测案例:三类高频函数一次过
4.1 二次函数:y=x^2-2x-3
我拿教学场景里最典型的二次函数做第一轮测试。输入是“画一下y=x^2-2x-3,区间从-3到5”。智能体先提取表达式,代码节点计算了401个采样点,自动把Y轴范围调整到包住最低点附近,SVG图里的抛物线开口向上,顶点在(1,-4),x轴交点大约在-1和3。
这里让我特别满意的不是图像本身,而是代码节点还能顺手算出关键点。我在同一个节点里加了几个方法,把asc/desc、顶点、零点都算出来,塞回返回参数里,最后大模型节点会补一句:“顶点坐标为(1,-4),与x轴交于(-1,0)和(3,0)。” 这种“图+关键点文字”的组合,比单纯出图更适合教学。
踩过的一个坑是:如果区间取得不对称,比如从0到5,Y轴自动缩放后顶点可能不在画布范围里,图像就会只显示右侧的那一段。我的解决办法是提示词里要求模型优先选择包含对称轴的区间,如果用户没指定,就以“足够展示关键特征”为目标推算默认区间。
4.2 三角函数:y=sin(x)
三角函数对采样点和坐标刻度要求更高,尤其是周期多个时会出现锯齿。第一次测试y=sin(x)时,我只采样了100个点,图像转折处明显有棱角。后来把采样数加到400,并把默认区间设为[-6.28, 6.28],也就是两个完整周期,图像就平滑了。
还有一个细节是坐标轴刻度标注。SVG里如果每隔0.5就标一个数字,图会很乱;每隔π/2标一个,学生又不太习惯。我的妥协方案是:只在坐标轴交点附近标几个整数刻度,然后在文字讲解里写出“周期为2π,振幅为1”。刻度在图上只是参照,详细数值交给文字解释,这样SVG生成的复杂度也更低。
另外,三角函数经常出现y=sin(2x)、y=2cos(x-1)这种复合形式,模型提取表达式时很容易把括号写错。我在提示词里专门放了一个例子:用户输入“二倍的cos括号x减一”,模型输出2*cos(x-1),而不是2*cos*x - 1。这类问题靠代码兜底不了,必须在入口提示词里做好轻度规范。
4.3 分段函数与定义域限制
教学里还有一类高频题:分段函数、分式函数、带根号函数,它们的定义域或者存在间断点,直接从头连到尾会画出假曲线。例如 y=1/x,在x=0附近函数值会突然跳到正负无穷,如果代码还是用一条折线把两侧连起来,就会在中间画出一条几乎垂直的竖线,这是错的。
我在采样循环里加了断点检测:当相邻两个点的Y值变化超过一个阈值时,就认为发生了间断,当前这段就断开。代码里我写的判断条件abs(yv - ys[-1]) > abs(ys[-1]) * 50 + 50,就是为了应对这种跳跃。实测下来,y=1/x、y=tan(x)、y=log(x-2) 都能正确显示出定义域边界和渐近位置。
对于分段函数,比如“y=x当x<0,y=x^2当x>=0”,纯靠一个表达式节点做不了。我把代码节点扩展成接收一个列表,每个分段有独立的表达式和区间,然后按区间分别采样,最后拼到同一个SVG里。这个扩展花了一个多小时,但做完之后智能体才真正像“会画图的教学题解工具”。
5. 常见问题与排查心得
5.1 坐标轴、刻度、中文乱码
这几个问题在调试第一天集中爆发。坐标轴反了,是因为SVG的Y轴正方向向下,我没有做转换;刻度间距不一样,是坐标轴没有按照“像素距离 = 数学距离 × 固定比例”来计算,而是随手写死了一个数;中文乱码,是因为平台返回的文本编码和SVG头声明的编码不一致。
我现在把所有标签统一成英文,网格和轴的颜色固定,避免字体问题。刻度数字直接通过text元素生成,虽然不如ECharts交互能力强,但胜在简单可控。如果一定要在SVG里显示中文,请在<svg>里声明xmlns和字形,并用font-family="sans-serif",同时在代码节点返回前做一次UTF-8的base64编码,不要直接拼接字符串传给前端。
5.2 表达式注入与非法输入
安全是代码节点第一优先。我见过不少组装的智能体,把用户输入直接拼进Pythoneval,一旦遇到恶意样例,后果很严重。我在代码里用ast做了严格白名单,只允许数学函数和常量,配合{"__builtins__": {}}限制环境,基本拦住常见注入。
但有一个漏网之鱼:模型自己会把用户的话转成表达式,比如“y=2的x次方”会被模型转成2**x,这没问题。“y=以2为底x的对数”模型可能生成log2(x),而我的白名单里没有log2,导致计算失败。后来我在白名单里加了log2,并且提示词里告诉模型:如果用户说“以a为底b的对数”,一律转成log(b)/log(a)的标准形式。这类边界条件需要不断从测试反馈里补。
5.3 速度优化与上下文记忆
第一次完整跑通时,整个工作流要三四秒,其中代码节点占了大部分时间。后来我把采样点从1000降到401,在准确度和速度之间平衡,单次绘制稳定在1秒左右。如果同一个函数在短时间内被反复请求,我会在节点里维护一个简单的缓存字典,key是表达式加区间,value是生成的base64字符串,命中后直接返回。
多轮对话也是教学场景的刚需。学生第一轮问“画y=x^2”,第二轮再问“如果k=2,图像怎么变”,单次工作流会丢失前面的函数。我的处理是在智能体预设变量里保存上一次的表达式的哈希,提示词要求模型在用户表述不完整时优先读取历史记录。这个不完全可靠,但对“上一次函数改参数”这种简单追问,基本够用。
下面把几个高频问题整理成速查表,方便排查:
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 图像上下颠倒 | SVG的Y轴方向未翻转 | 用M + (y_max - y) / (y_max - y_min) * plot_h映射 |
| 曲线在中途拉出竖线 | 采样点跨过间断点 | 增加断点检测,Y值跳变过大时断开折线 |
| 中文乱码或方块 | 字体和编码不匹配 | 标签用英文,保存和返回时统一UTF-8 |
| 图太模糊 | 采样点太少 | 优化到400~600个点,兼顾速度和平滑 |
| 函数结果全是空白 | 表达式解析失败 | 查看日志定位是语法错误还是没有输出的函数段 |
| 学生输入特殊字符导致崩溃 | 表达式注入 | 用ast白名单校验,禁掉__builtins__ |
这次折腾下来,我个人最深的体会是:教学智能体里,凡是涉及“精确性”的功能,不管是函数图像还是几何证明过程,都要把执行权从语言模型手里收回来,放到一个可测试、可观测、可回滚的代码节点里。模型更适合做翻译和总结,不适合做尺规作图。算法逻辑越“笨”,结果越可靠。
最后再分享一个小技巧:与其让模型每次从零理解“画图参数”,不如预置五个高频函数模板(一次函数、二次函数、三角函数、反比例函数、带根号函数),让模型先匹配模板再填空。这个做法把成功率直接从70%拉到了95%以上。后续我还打算在同一个节点里加入隐函数绘图和参数方程支持,方向上还是“LLM抽取参数,代码负责计算渲染”,这套架构往深走还有很多可以玩的东西。