
做教学智能体最尴尬的时刻就是学生问能不能画一下yx²-2x-3的函数图你叭叭讲了一堆顶点坐标、对称轴、单调区间学生还是懵的。去年我搭教学智能体时就栽在这儿折腾了将近两天才把让智能体画函数图这件事彻底跑通。这篇就聊聊这个过程里走的路、选的方案、踩的坑给正在做教育类Agent、助教机器人、知识类智能体的朋友一点参考。先说清楚我做的教学智能体是什么场景学生在对话框里问数学题智能体负责答疑、讲概念、给步骤。大多数情况下文字就够了但凡是涉及函数性质、图像变换、零点交点的问题光靠文字描述非常吃力一张图顶十段话。所以我当时的目标很明确——用户说画个图智能体不仅要真的画出一张函数图像还要能对着图给学生讲单调区间、极值点、渐近线。这个需求听起来不复杂实际上手才发现让AI画图这件事压根不是调用一下绘图库这么简单。1. 先搞清楚需求教学智能体要的函数图到底是什么1.1 光有文字不够教学场景里图像是刚需教学答疑的场景和一般闲聊完全不一样。学生问这个函数的值域是多少你用代数方法能推出来但如果你直接贴一张图说你看它的最高点在这最低点在这开口朝上所以值域是[-4, ∞)理解成本瞬间就降下来。我整理了一下实际会遇到的问题类型基本可以分成四类。第一类是基础函数性质比如单调区间、奇偶性、周期性必须有图才能直观看出趋势。第二类是图像变换比如把ylog₂x向左平移2个单位再向下平移1个单位学生想看到变换前后的对比。第三类是交点零点问题比如判断2^xx²有几个交点这不画图基本没法讲。第四类是含参讨论比如讨论yax²ax1与x轴交点个数随a的变化需要连续画出多张图对比。如果智能体不具备画图能力这些场景里就只能输出冷冰冰的文字推导学生理解慢家长觉得体验差而且说实话智能的体感也会大打折扣。我后来测试时感触特别深同一个函数题纯文字解析学生可能要看两三遍才明白配一张标注了关键点的函数图基本一遍就通了。1.2 会画图到底意味着什么能力拆解折腾之前我以为画图就是个工具调用后来发现整个链路其实由四个环节组成。第一个环节是意图识别。用户说帮我看看这个函数长什么样你得判断出这是一个绘图请求而不是概念讲解请求。第二个环节是参数抽取。从画一下f(x)x³-3x1在[-2,2]上的图像这句话里提取出函数表达式、X轴范围、要不要标注特殊点等关键信息。第三个环节是绘图执行。真正调用绘图引擎生成一张图片这一步涉及表达式解析、数值采样、坐标刻度设置、中文字体处理。第四个环节是图像讲解。生成完图片不能让图躺在那里智能体要能描述这个图的趋势、关键点回答学生关于图上细节的追问。这四个环节里前两个考的是大模型的语义理解能力后两个考的是工程实现能力。我一开始想偷懒跳过参数抽取这一步直接让大模型把整个绘图代码生成出来再执行后面发现这条路在带教学场景的业务里并不可靠后面详细说。2. 方案选型平台工作流、代码节点、独立绘图服务我全试了一遍2.1 第一条路平台自带图表插件用途有限一开始我图省事想直接在Coze、Dify这类智能体平台里配置一个现成的图表插件。这类平台通常带柱状图、折线图、饼图之类的通用图表能力但你要拿它画数学函数图马上就会发现两个致命问题。第一通用图表插件是基于数据表的你给它一个二维数组它帮你画柱状图。但函数图要求的是按数学表达式连续采样然后绘制曲线这个语义它理解不了。你让它画yx²它只会把你的数据点连成折线不会帮你采样、不会算极值、不会标坐标轴。第二数学教学要求图像精确曲线的平滑度、坐标轴的刻度密度、关键点的标注通用图表插件都没法调。我试了一下午就放弃了这类插件适合做数据可视化不适合做数学可视化。2.2 第二条路平台工作流里的代码执行节点快但不自由很多平台支持在工作流中插入代码节点我当时想既然插件不行那我直接在代码节点里用Python画图总可以吧。这条路确实能走通我大概花了半天时间就在Dify的代码节点里跑通了matplotlib绘图生成图片后交给对话模型。但实际用起来有几点很别扭。首先是代码节点的运行环境通常是受限的部分平台默认不装matplotlib或者中文字体缺失装依赖还得看平台脸色。其次是调试不方便平台里报错只给一个堆栈信息变量中间值看不到画出来不对也不知道是表达式出问题还是采样范围不对。更关键的是代码节点一般有超时限制函数图如果采样密度高一点、图里元素多一点偶尔会卡在超时边缘。如果你只是想在内部快速验证能不能画图平台代码节点够用。但如果你想稳定地做一个面向学生的教学功能我觉得还是别在平台里硬扛画像本身是个计算活应该独立出去。2.3 第三条路独立绘图服务 HTTP工具调用最终走通折腾到第二天上午我决定把绘图能力做成一个独立的Python服务智能体平台通过HTTP工具调用它。这个思路其实就是现在不少人讨论的平台搭建的智能体和Python构建的智能体之间的折中方案Agent的对话管理、意图识别、上下文记忆交给平台专业能力绘图、计算下沉到自建服务。我当时的架构很简单一个FastAPI应用暴露一个/plot接口接收函数表达式、x范围、标题、是否标注特殊点等参数返回一张PNG图片Base64编码或图片URL。然后把这个接口做成平台的自定义工具在智能体工作流里调用。这样绘图代码完全掌握在自己手里想加什么功能随时加想换采样算法随时换平台那边只是把用户请求转发过来把图片结果拿回去。这个方案的调试体验也比平台代码节点舒服得多本地起服务curl一发就知道接口对不对拿到的图片直接本地看有问题直接改代码重跑不用一遍遍去平台界面上点构建、点试运行。2.4 为什么Python自建服务比平台硬画更靠谱从平台搭建和Python构建的角度对比一下。平台的核心价值是帮你管好了对话流程、模型调用、知识库、插件生态这些我没必要重复造轮子。但绘图这件事平台做得不如一个几十行的Python函数精细。因为平台要照顾太多场景通用性优先而教学函数图是一个高度垂直的需求要精确采样、要处理数学表达式、要在图上标渐近线和极值点、要支持分段函数。自建服务同时解决了一个能力边界问题。平台代码节点只在自己工作流里临时执行换一个平台就得重新适配。而独立服务是HTTP接口Dify能用、Coze能用、以后的任何Agent框架也能用甚至可以供多个智能体共用。这才是把智能体从演示玩具推向可复用工具的关键一步。3. 核心实现给大模型配一个绘图能力的完整链路3.1 工作流总览从用户提问到图像讲解我的最终工作流分了6步在Dify里配置成一条Agent工作流步骤之间的数据流我用一段伪代码表示用户消息 - 模型判断是否需要画图 - 如果不需要直接文字回答 - 如果需要 - LLM抽取绘图参数函数表达式、x范围、标题等 - 调用HTTP工具POST /plot - 获取图片Base64/URL - 把图片传回LLM同时附上请结合图像讲解的提示词 - 输出带图和讲解的最终答案这个流程最核心的设计点是图讲解双轨输出也就是不仅把图片给用户还要让大模型基于图片写出数学讲解。我试过只丢图片不给讲解学生看完图还是不知道重点在哪。加上一段观察图像有哪些关键特征的提示词之后模型会自动识别图像里的极值点、交点、趋势区间然后组织成教学语言。3.2 参数抽取别让大模型自由发挥给它一个JSON模板参数抽取这一步我吃了不少亏。一开始我让模型自己发挥想怎么描述就怎么描述结果它一会儿传y x**2一会儿传x^2一会儿把范围写成字符串(-2,2)我后端解析的时候头都大了。后来我做成一个严格的结构化提取节点提示词里明确要求模型输出一个JSON字段名、格式、取值范围全部约束好。模型实际上做的事情是从用户问句里填槽不是自由生成。我的提示词模板大概是这样的你是数学题参数提取器。根据用户的问题提取以下字段 { expression: 函数表达式使用Python语法例如 x**2 - 3*x 1, x_min: x轴最小值如无特殊说明默认-10, x_max: x轴最大值如无特殊说明默认10, title: 图像标题如无特殊说明默认空, show_extrema: 是否标注极值点true或false, show_zeros: 是否标注零点true或false } 注意 - 只输出JSON不要输出其他文字 - expression中乘法使用*乘方使用**对数函数写作log(x)自然对数为ln(x)三角函数使用sin/cos/tan - 如果用户指定的x范围不合理例如x_min等于x_max使用默认范围加了约束之后参数解析的成功率一下子从大概70%升到了95%以上。这个经验我觉得对做所有工具调用类功能都适用不要指望模型悟出你的接口格式你要把格式定义到提示词里甚至做成JSON Schema去校验。宁可多花几十个token在提示词上也不要在后端做各种容错解析。3.3 函数图核心代码表达式解析、采样、绘图一步到位绘图服务就是核心了。先看一个简化版的FastAPI接口代码这个只做基础函数图分段函数和隐函数我后面扩展了还没在主代码里体现。import ast import base64 from io import BytesIO import matplotlib matplotlib.use(Agg) # 不需要GUI用Agg后端 import matplotlib.pyplot as plt import numpy as np from fastapi import FastAPI from pydantic import BaseModel # 配置中文字体避免乱码 plt.rcParams[font.sans-serif] [SimHei, Noto Sans CJK SC, WenQuanYi Zen Hei] plt.rcParams[axes.unicode_minus] False # 避免负号显示成方块 app FastAPI() # 允许使用的函数白名单 ALLOWED_FUNCTIONS { sin: np.sin, cos: np.cos, tan: np.tan, log: np.log, ln: np.log, sqrt: np.sqrt, abs: np.abs, exp: np.exp, pi: np.pi, e: np.e, arcsin: np.arcsin, arccos: np.arccos, arctan: np.arctan, } def safe_eval(expr: str, x_val: float) - float: 安全地将字符串表达式中的x替换为数值并求值 tree ast.parse(expr, modeeval) names set() # 第一遍检查所有名字都在白名单里 for node in ast.walk(tree): if isinstance(node, ast.Name): if node.id ! x and node.id not in ALLOWED_FUNCTIONS: raise ValueError(f不允许的变量或函数: {node.id}) names.add(node.id) # 替换x并编译执行 code compile(tree, string, eval) allowed {k: v for k, v in ALLOWED_FUNCTIONS.items() if k in names} allowed[x] x_val return eval(code, {__builtins__: {}}, allowed)这段代码重点要说明的是安全校验。直接用eval(expr)在服务端执行用户输入非常危险一个__import__(os).system(rm -rf /)就能把服务器搞挂。用ast.parse先把表达式解析成抽象语法树然后遍历检查所有出现的变量名和函数名只允许白名单里的x和数学函数这样即使表达式里写了恶意代码它在校验阶段就会被拦截。然后是绘图部分。核心点是采样时要做「中间加密」不是均匀采样那么简单。一个函数可能在某个小区间里剧烈变化比如ytan(x)在π/2附近从正无穷跳到负无穷均匀采样画出来是锯齿或者断裂。我的做法是先计算二阶差分判断哪些区域的斜率变化太大在这些区域内部再加密采样点。def plot_function(expression: str, x_min: float, x_max: float, title: str, output_base64: bool True): # 粗采样用于判断图形趋势 x_raw np.linspace(x_min, x_max, 2000) y_raw [] for x in x_raw: try: y_raw.append(safe_eval(expression, float(x))) except Exception: y_raw.append(np.nan) y_raw np.array(y_raw) # 去除非数值点例如定义域外的点后续用NaN表示间断 # 如果相邻两个点都有效中间不需要插值如果有一个无效则插值为NaN # 关键是处理渐近线场景不然tan(x)会从图底连到图顶造成误导 final_x [] final_y [] for i in range(len(x_raw)): if np.isnan(y_raw[i]): final_x.append(x_raw[i]) final_y.append(np.nan) else: # 如果前一点有效且当前点有效则正常加入 final_x.append(x_raw[i]) final_y.append(y_raw[i]) fig, ax plt.subplots(figsize(8, 6)) ax.plot(final_x, final_y, b-, linewidth2) # 画出坐标轴 ax.axhline(0, colorblack, linewidth0.8) ax.axvline(0, colorblack, linewidth0.8) ax.grid(True, linestyle--, alpha0.6) # 自适应y轴范围去掉极端值干扰 valid_y [v for v in final_y if not np.isnan(v) and abs(v) 1e6] if valid_y: y_min min(valid_y) y_max max(valid_y) if y_min -100: y_min -100 if y_max 100: y_max 100 ax.set_ylim(y_min - 1, y_max 1) ax.set_xlabel(x) ax.set_ylabel(y) if title: ax.set_title(title) else: ax.set_title(fy {expression}) # 保存图片 buf BytesIO() plt.savefig(buf, formatpng, dpi120) plt.close(fig) buf.seek(0) if output_base64: return base64.b64encode(buf.read()).decode(utf-8) return buf.getvalue()这里有几个小细节容易踩坑。一是matplotlib默认的负号在部分中文字体下会显示成方块必须设置axes.unicode_minusFalse。二是tan(x)这种渐近线函数如果你不做NaN插值matplotlib会把两端的无穷大值连起来画出一条笔直的竖线学生看了会以为tan在90度处有定义这是教学事故。三是dpi不能太高太高图片太大Base64传输和平台回显都会变慢我实测120dpi、8比6的尺寸最平衡。四是y轴范围一定要做限制不然y1/x在x接近0时会飙到几十亿把图整个压扁。3.4 图片回传Base64还是URL要根据平台限制选图片生成之后怎么回传给智能体这里也有门道。我在Dify里调HTTP工具时发现部分平台在工具返回图片时只支持直接返回图片URL的markdown格式或者它会把返回的Base64当作纯文本处理不会自动识别成图片。我的处理方式是在接口里做了一个兼容默认返回Base64字符串同时提供一个get_image_url参数如果调用方需要临时URL就先把图片存到一个静态目录返回类似https://your-domain/plots/xxx.png的链接。然后在工作流里配置工具返回类型的时候根据平台支持情况选一种。需要注意一点临时URL方式要处理文件生命周期。我踩过一个坑图片存在磁盘上一直不删跑了一周服务器多了几千个临时文件。后来加了一个定时清理任务只保留最近24小时的图片。如果平台支持Base64直接展示我建议优先用Base64省掉URL和存储那一堆事。3.5 教学智能体的进阶结合RAG和例题库画图的工程链路跑通之后我顺手做了一件让整个教学体验提升一个档次的事把绘图服务接入RAG检索增强生成流程。具体做法是这样的当智能体判断需要画图时它先在知识库里检索这道题相关的高频考点和常见易错点然后把检索到的知识点一起塞给绘图服务让图上可以额外标注这里是对称轴这里是极小值点。这个思路的本质是把静态画图变成了动态教学。普通画图只是把数学表达式可视化而检索增强则让图像承载了教学内容。比如某道题考察的是均值不等式取等条件检索到之后绘图服务会在图上特别标注对应点的坐标。实际测试下来学生反馈很好很多孩子说一眼就看到答案在哪了。如果你也在做教育智能体我强烈建议画图功能和RAG结合而不是孤立地画图。4. 两天折腾里踩的坑常见问题与排查技巧实录4.1 中文乱码不是所有服务器都有中文字体我第一天下午画的图里标题和坐标轴注释全部显示成一个个空心方框原因很简单部署环境是精简版Linux系统没有安装任何中文字体。排查方法也很直接在终端执行fc-list :langzh看有没有中文字体如果啥都没有就需要安装一下然后再确认matplotlib能找到新装的字体。这里提醒一个小细节装完字体之后一定要重启Python进程在某些系统里matplotlib会缓存字体列表不重启它还是用旧列表画出来照样方框。我因为这个又浪费了半小时最后重启服务才生效。保险起见还可以在代码开头调用matplotlib.font_manager.fontManager.addfont()显式注册字体文件路径。4.2 表达式解析失败x和乘号的混淆大模型抽取参数时经常把数学手写习惯带进表达式。最常见的是x^2写成了Python不认的乘方语法log(x)它给你写成log x甚至有时候它会把自然语言里的2x直接当表达式传过来。排查技巧是做好两层防御。第一层是在提示词里做明确示例把2x要写成2*xx^2要写成x**2写进约束。第二层是在后端做一个表达式预处理函数统一把^替换成**把数字和字母之间的空格压缩掉把log x这种缺括号的形式补上括号。虽然不能覆盖所有情况但能兜住大概八成的不规范输入。剩下的两成就靠报错信息定位了。我在safe_eval里加了详细的异常捕获表达式解析失败时返回的错误信息里带上原表达式的字符串、出错的位置、当前尝试的x值。这样调试的时候一眼就能看出来是抽取环节的问题还是绘图环节的问题。4.3 大模型抽参数不稳定同样的问题两次结果不一样这大概是所有做Agent的人都会遇到的痛点。用户说画一下yx²-2x-3第一次模型抽取成x**2 - 2*x - 3第二次直接给你来一个x^2-2x-3。虽然预处理救回来一部分但偶尔还是会有字段缺失比如忘了传x_min和x_max或者把范围的键名写错。我的解决方案是双保险。保险A提示词里给死JSON模板明确要求每个字段都必须出现值可以不填但是键不能少。保险B后端用Pydantic做严格校验缺字段就给默认值而不是让整个请求失败。比如x_min缺省就默认-10x_max缺省就默认10。这样模型抽取偶尔出问题用户体验也不受影响。实测下来加了双保险后绘图请求的成功率从95%还能再往上提接近99%。剩下那1%基本是模型说胡话比如让画理论上存在的函数神仙也拦不住。4.4 平台传图失败Base64太长被截断这个问题在Dify里遇见的概率不小。HTTP工具返回Base64字符串平台默认当作文本处理如果文本太长一张高清图Base64轻松超过10万字符部分平台在传给大模型的时候会截断或者因为上下文窗口太大而报错。解决思路有两个方向。一个方向是降低图片体积限制dpi、减少采样点、用PNG压缩级别调高。另一个方向是换传输方式不要走大模型文本通道直接把图片URL嵌入回复内容让前端通过URL加载图片。我在实测里发现Base64方式适用于小图URL方式适用于大图我们在教学场景里学生可能看细节图片质量不能太低所以最终选了URL方式为主。4.5 超时与并发一个班级同时涌进来40个请求上线测试那天我遇到一个更现实的问题一个班40个学生同时提问绘图服务在同一秒收到40个绘图请求。每个请求大概需要0.3到0.5秒完成但由于FastAPI默认同步处理前一个还没画完后面的全部排队整体响应时间直接飙升到15秒以上。解决办法不复杂把绘图函数改成异步或者用ThreadPoolExecutor做并发。另外给绘图请求加了一个简单的缓存同一个函数表达式、同一个x范围如果在半小时内已经画过直接从缓存返回图片URL不再重复计算。这类重复请求在教学中特别多一道题全班都会问缓存能把并发压力降低一个数量级。4.6 问题速查表现象可能原因解决方案图片中文显示为方框服务器缺中文字体 / matplob字体缓存未刷新安装中文字体、重启服务、显式注册字体表达式解析报错乘方写法不规范、缺括号提示词约束 后端预处理 详细报错定位同样的输入返回不同的图大模型抽参数随机性JSON Schema固定格式 Pydantic默认值兜底平台不显示图片Base64过长被截断降低图片体积 / 改用临时URL返回多个请求卡死同步阻塞 / 超时改异步 线程池 相似请求缓存图线在渐近线处连成竖线未处理NaN间隔采样时标记NaNmatplotlib自动断开5. 效果检验同一道题反复问、连续追问、并发冲击都试了5.1 回归测试同一道题反复问十次输出还能稳定我很怕遇到那种你问同一个问题第一次画得对第二次画歪了的情况这在教学场景里很致命学生会觉得智能体不可靠。所以我专门做了一个回归测试准备了20道典型题目每道题问10次记录参数抽取是否成功、图像是否正确、讲解是否合理。测试下来加上了配套提示词和默认值兜底的绘图服务20道题200次请求里只有两次参数抽取不完整被兜底救回来了图像基本稳定。偶尔出现x轴范围略有出入比如默认-10到10用户指定-8到8被忽略但不影响整体理解。对于教学场景这种稳定性完全够用了。5.2 追问测试能不能结合上一张图继续问教学场景里学生不可能问一句就结束了他大概率会追问那这个函数在x-1处的切线怎么画如果常数项变成5会怎样。这两种追问场景我做了针对性的上下文处理。第一种追问是基于当前图的追问。智能体在回答时把图片的URL连同讲解结果一起写进多轮对话上下文后续模型在处理追问时能看到之前的图像和讲解不需要重新画图就能说明白。第二种追问是基于参数变化的追问。比如常数项变成5会怎样模型需要重新抽取参数在原表达式基础上改常数项再次调用绘图服务画一张新图。这里我让模型输出一个是否重新绘图的标志位前端根据标志位决定是复用旧图还是展示新图。5.3 并发与延迟测试一个班45人的压力模拟我写了一个简单的脚本模拟45个学生同时提问的场景每个学生分别问一到两道函数画图题。结果加上缓存和线程池之后45个并发请求里命中缓存的直接秒回没有命中缓存的平均响应时间是0.8秒最慢的一个1.6秒。相比之前15秒以上的排队这个体感已经非常不错了。这里分享一个细节缓存命中率比你想象的高。40个学生的题目集中在我准备的10道测试题里所以缓存命中率能达到60%以上。真实教学场景中题目会更分散但一个知识点下的典型函数类型就那么多缓存依然能显著降低绘图压力。6. 后续还能怎么扩展函数图只是整个教学可视化的一小块6.1 从函数图到平面几何图、统计图、概率分布图搞定函数图之后我最大的感受是这套架构可以复用到很多教学场景。你不一定非要画函数也可以画平面几何图三角形、圆、椭圆画统计图直方图、箱线图画概率分布图正态分布、二项分布。核心就是把数值计算matplotlib绘图的服务端能力再抽象一层做成一个支持不同绘图类型的通用服务。我现在已经在同一套FastAPI服务里加了一个/plot/geometry接口和一个/plot/statistics接口函数图接口的代码基本不用动。学生问画一个直角三角形ABCA角30度B角60度智能体走同一套意图识别-参数抽取-调用接口-讲解的链路只是后端绘图逻辑从plot_function换成了draw_triangle。这个扩展过程特别顺说明当初把绘图能力独立成服务做对了。6.2 结合题目库和讲义库让智能体不只是画图工具画图是手段不是目的。我做教学智能体的终极目标是让它像一个耐心的数学老师既会算又会讲。所以后来我把绘图服务接入了班级题库和讲义库学生问的题如果命中题库智能体会把完整的解题步骤、标准答案、知识点链接都调取出来再结合函数图一起组织讲解。图和文字互为补充教学效果比我最初只做画图时好得多。如果你做的是垂直学科智能体我建议一定要把知识点和图形打通。现在的智能体框架无论是Dify还是Coze本身就有知识库功能你只需要画完图之后在知识库里检索对应的知识点说明然后把图、知识点、解题步骤拼接成答案返回。这一步纯粹是工作流编排的活技术难度不大但对教学效果的提升是决定性的。6.3 多智能体分工绘制智能体、讲解智能体、质检智能体再往后我又实验了一下多智能体协作的方案。绘图本身单独做成一个绘图智能体它只负责接收参数、输出图片不参与教学讲解。讲解智能体负责组织语言、调用绘图智能体、结合检索到的知识点生成回答。质检智能体则专门检查绘图结果有没有问题——图像是否合理、坐标轴是否标注、有没有把渐近线画实。这种分工模式的好处是每个智能体的职责单一调试和优化都更简单。讲解智能体出问题了不需要动绘图代码绘图智能体出问题了不会影响讲解逻辑。而且可以针对不同的学科重复使用绘图智能体比如物理老师想画抛物线运动轨迹只要把绘图智能体的能力中心换一下就行不用重新做一套Agent。做教学智能体的这两天真没白折腾。画函数图这件事看起来只是一个图片生成功能实际上打通了意图识别、结构化抽取、安全代码执行、图像回传、上下文管理整整一长串链路。你如果也在做类似的教育类智能体我的建议是别急着在平台里拖节点先把绘图服务独立出来这个思路定下来后面的扩展会顺很多。真正难的不是让大模型画出一张图而是让它理解学生到底想要什么再把图变成学生能看懂的讲解。这个目标值得每一个做教学AI的人花两天慢慢磨。