ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

码道:我用 DeepSeek 大模型,给家里做了一个会讲题的加减法口算助手

码道:我用 DeepSeek 大模型,给家里做了一个会讲题的加减法口算助手 码道我用 DeepSeek 大模型给家里做了一个会讲题的加减法口算助手一、事情的起因是昨天那张皱巴巴的题卡先说说这个东西是怎么来的。我家小朋友上一年级每天雷打不动要练二十道口算题。之前家里买过两本口算本本子用完就换新后来干脆去打印店打题卡一张纸五十道写完就扔。纸卡有一堆毛病字太小、油墨沾手、背面总是印着别的公司的宣传广告。但真正让我抓狂的不是这些是讲题。孩子答错了 17 - 8我说借位他听不懂他妈说破十法他发呆我换个说法8 和几凑成 17他更懵了。同一个问题三个人三种讲法孩子越听脸色越白。我在旁边看着有种说不出的无力感。那段时间我总在琢磨能不能有个工具既能源源不断地出题、能立刻告诉他答案对错还能用他这个年纪听得懂的话讲给他听出题、判题这两个需求很朴素朴素到用几行 JS 就能写。难的是讲题。你很难把一个小学生的解题思路提前写死在代码里因为同一道题可以有七八种讲法。但要是让一个大模型来讲呢念头一起就停不下来了。二、为什么是网页加原生三件套先说结论这个小工具就是一个纯前端的网页HTML5 CSS3 JavaScript零框架零构建零依赖。我在动手前也纠结过形态。做成小程序要注册要审核更要命的是小程序里调第三方接口要配合法域名麻烦做成 App先得齐一套打包发版的流程为一个口算练习小题大做。最后选了网页。理由很实际家里任何一台电脑、一个平板打开浏览器就能用不用装任何东西也不用怕版本升级。技术栈选原生三件套一部分原因是够用另一部分原因是省心。我不用引入打包工具不用维护依赖版本更不用在半夜被哪个 npm 包的 breaking change 唤醒。一个 HTML 文件加两个 JS 文件其中一个还是纯配置拿起来就能跑。这对一个给小孩子用的内部工具来说正合适。唯一要动脑子的是讲题那一环。我的打算很直接把聊天的活儿交给大模型网页负责把模型说的话变成好看、好玩的界面。三、接口与参考代码一段看起来很眼熟的 Python我拿到的参考资料是这样一段 Python 代码importrequests API_URLhttps://api-ai.gitcode.com/v1/chat/completionsheaders{Authorization:Bearer 你的Key}defquery(payload):responserequests.post(API_URL,headersheaders,jsonpayload,streamTrue)forlineinresponse.iter_lines():ifnotline.startswith(bdata:):continueifline.strip()bdata:[DONE]orline.strip()bdata: [DONE]:returnyieldjson.loads(line.decode(utf-8).lstrip(data:).rstrip(/n))chunksquery({model:deepseek-ai/DeepSeek-V4-Flash,messages:[{role:user,content:告诉我一个有关宇宙的有趣事实}],stream:True,max_tokens:2048,temperature:0.6,top_p:0.95,frequency_penalty:0,thinking_budget:2048})forchunkinchunks:print(chunk[choices])这段代码短但信息量足接口走的是 OpenAI 兼容的/chat/completions开启流式后服务端一行一行往外吐data: {...}这样的 SSE 分片结尾给一个data: [DONE]收尾。模型是deepseek-ai/DeepSeek-V4-Flash还带上了一个thinking_budget参数说明这个模型默认会先思考再回答。我用 curl 快速验证了一下接口通不通结果一次就通了返回的 JSON 结构规规矩矩。心里踏实了一半剩下的一半压在怎么在浏览器里重现这个流式读取上。顺带说一句 SSE 这个协议本身。它全称 Server-Sent Events是 HTTP 上面的一种文本流格式约定每条消息写成data: 内容多行消息可以拆成多个data:行最后用一个空行隔开。很多国产大模型平台都复用了这套约定来推送 token和 OpenAI 的做法保持兼容。所以你在浏览器里其实完全不用去写什么 WebSocket 的握手一个普通的fetch就够稍微压点代码量。流式方案的另一个好处是省内存模型一吐字节浏览器就收不用等整篇生成完再一口气塞给页面。对小孩练口算这种场景等待体感很重要见字如面比转一个无限期的圈好得多。顺带一提resp.ok和resp.body这两个判断在我这里也算关键分支跨域或鉴权失败时接口会返回一个不带流的 4xx 响应如果直接盲目去getReader()读抛出来的错误信息会很费解。先看一眼状态码再把错误文本截一段带到提示里排障时间能省一半以上。四、最难的部分把 Python 的流式读行翻译成浏览器的流式读字节requests的流式处理其实很简单iter_lines()帮你把字节流一行一行切好你拿到一行剥掉data:前缀json.loads一下完事。它是阻塞式的一行一行慢慢读主线程等着就行。浏览器里完全不是这个心智模型。fetch拿到的是response.body一个ReadableStream。你得手动getReader()然后循环reader.read()每轮拿到一小段Uint8Array。难点在于字节流是裸的服务端不会贴着你的请求节奏切分。上一段和下一段合在一起可能才拼出一个完整的 JSON也可能半路切开某个 chunk把一句完整的话从中间劈成两半。我的做法是维护一个字符串缓冲constreaderresp.body.getReader();constdecodernewTextDecoder(utf-8);letbuffer;while(true){const{value,done}awaitreader.read();if(done)break;bufferdecoder.decode(value,{stream:true});constlinesbuffer.split(\n);bufferlines.pop();// 最后一段可能是不完整的一行留到下轮for(constlineoflines){consttline.trim();if(!t.startsWith(data:))continue;constdatat.slice(5).trim();if(data[DONE]){closeStream();return;}// 解析 data 里的 JSON取 choices[0].delta}}这段代码踩了三个细节我一个个说。第一split(\n)之后最后一个元素要放回 buffer因为那可能只是半行。这是流式解析最容易被忽略的地方少了这一步数据偶尔会缺胳膊少腿。第二decoder.decode(value, { stream: true })里的stream: true不能省。多字节字符比如中文的 UTF-8 编码可能被两次 read 劈开加了流式选项TextDecoder 会记住没接完的字节。第三也是让我栽了一跤的delta里同时有content和reasoning_content两个字段。reasoning_content是这个模型的思考过程会先一大段地流出来之后才是真正给人看的content。我第一次写的时候只取了content页面上看半天一个气泡空白Console 里也没有报错。后来把两个字段都打印出来才看到模型在题还没出之前先默默推演了一长串。这两股流得分开处理推理过程收进一个可折叠的小面板正文才逐字显示在气泡里。五、系统提示词逼着一个会说话的模型说人话 JSON接口通了之后我面临第二个设计决策让模型直接聊天式地输出还是让它输出结构化数据直接聊天最容易但页面不好看。模型一句话里又是算式又是讲解又是鼓励混成一团我想定制题目要有大字、答案要填进输入框、讲解要列成步骤就变得非常别扭。与其在页面端做一堆正则去拆不如把结构化的活儿交给模型。于是我在系统提示词里立了一条规矩每一轮只输出一个 JSON 对象禁止输出 JSON 以外的任何文字禁止用代码块包裹 JSON。然后给出一份明确的 schema{type:question | explain | chat,message:写给小朋友的一句话简短温暖,question:{text:7 5 ?,answer:12,topic:加法 | 减法,difficulty:easy | medium | hard},steps:[先算 7310,再把剩下的 2 加上10212],tip:一句小提示或口诀}type字段是我跟页面定好的暗号question就渲染题目卡片explain就展示分步讲解chat就当成普通聊天气泡。question里text和answer是核心前端题目卡直接喂给它们。steps和tip是加分项答完题之后展开当课后讲解。为了把模型约束住我在提示词里写死了很多边界难度分三档每档卡住结果范围easy 不超过 10medium 不超过 50hard 不超过 200防止它一下子冒出来几百几百的两位数连加减。answer必须是它自己算准的整数这一条能顶十句你要认真。每一步讲解要用孩子听得懂的话比如先数出 7 个手指头再往回数。后补的一条上一轮刚出过的题这一轮换个数字别重复。这些约束不是我拍脑袋写的。它经历了一轮迭代。第一版提示词只有一句话的规模大意是请返回题目及讲解的 JSON。结果是灾难性的模型确实给了我 JSON但形式五花八门有裹在代码块里的有在 JSON 后面又附一句祝小朋友学习愉快的最离谱的一次type字段被它写成了 “calculation”。我一个个问题往提示词里加补丁加一条测一轮加了五六轮才算稳住。整个过程的体感很像是在驯一只聪明的动物它不是不懂规矩它只是需要对规矩是什么反复确认。最后我把禁止输出代码块标记这句话换了个说法写进提示词输出干净了至少一多半。模型理解不要往往不如理解要什么可靠所以我干脆给它一份完整的示例对象让它照着抄比任何解释都有效。我一度也考虑过把 interface 定义写长一点让前端根据返回对象里的某个字段再去二次解析后来放弃了。原因是把解析责任过度下放给前端模型万一漏字段前端就得写一堆字段不存在就当没有的补丁线代码比逻辑还多。让模型一次性输出一份完整的、字段齐全的对象前端只做三件事解析、判断 type、按 type 渲染。这份契约越简单出问题的面就越窄。把提示词写稳之后我又把它丢进测试脚本连跑五类请求出加法题、讲减法、闲聊、出难题、算具体算式。七成以上的输出一次符合要求剩下两三成是因为题面里多了中文括号、或者是answer悄悄变成了字符串。这些偏差都没有拦路解析端的兜底把它们全接住了。这里顺便说一句我观察到的现象模型对禁止的理解是概率性的。你写禁止输出代码块它十次里有八次不输出但总有两三次手痒。所以生产环境永远不要指望提示词是 100% 的合同解析端一定要做容错。我的容错函数叫parseJson三层兜底很朴素functionparseJson(text){// 第一层直接解析// 第二层剥掉 json ... 代码块再解析// 第三层掐头去尾截取第一个 { 到最后一个 } 之间的内容解析}三层里第三层最救命只要内容里还有一对完整的大括号就绝不承认失败。六、判题为什么放在前端有个设计决策我到现在仍然觉得是对的判题不在请求里让模型做而是前端本地算。原因特别简单。出题阶段模型已经在 JSON 里给出了标准答案answer那是一道送分的信息。用户在输入框里敲一个数我parseInt一下跟answer一比立即出结果。答案对错是确定性的、零延迟的完全不需要再发一次请求等模型回传。相比之下让模型去判题多一轮网络往返不说还有被判错的风险。小孩子做题最怕的就是等让他盯着转圈等三秒判题跟让他等三秒才看见下一颗糖效果差不多。判题之后模型给的steps就地展开答对了放一句鼓励答错了把正确答案亮出来并列出解题步骤。再往下的操作是两个按钮下一题或者让 AI 单独把这题讲一遍。这里我想多说一句模型算错了怎么办。理论上把正确答案的计算权完全交给模型是有风险的因为它偶尔真的会算错。我在测试脚本里专门盯过这件事收到的answer大部分是准的但也出现过3 7出 11 这种荒谬的结果。所以我的页面写了一个很不起眼的防线题目卡渲染时如果question.answer缺字段或者text对不上算式结构前端宁可显示让小算算重新出一题也绝不让一道坏题带进答题流程。本地判题的想法也是出于同样的考虑既然我能自己算对为什么要把判错的风险交给网络那边的随机性。永远给模型可能出错这件事留一条退路这是我做完这个项目学到的最实在的一课。七、两个让我印象深刻的 bug写交互的时候我踩了个特别蠢的坑值得写出来纪念一下。题目卡片上我加了两个按钮下一题、请讲解一下。判完题之后如果模型在这题里自带了steps按我最初的逻辑要把步骤面板显出来同时……我把操作按钮区整个remove()掉了。当时心里想的可能是讲解都给了按钮别挡着页面。结果就是只要模型出了题并带了讲解图片卡上就再也找不着下一题的按钮了。这个 bug 不是用户报的是我自己写自动化测试时抓出来的。Playwright 脚本模拟答完题之后点下一题那个按钮死活点不到等了三十秒超时。我盯着定位器报错看了半天才回忆起来自己在判题回调里干过那件事。改成不删按钮、步骤栏显示在卡片下方问题立刻消失。这个小事故教会我一件事别在事件回调里做让 DOM 结构消失的优化宁可让它占地方。第二个坑跟流式解析有关。我最初判断这条回复是不是 JSON用的是第一个非空 chunk 的第一个字符是不是{“。但流式传输根本没有第一个 chunk 就是完整开头的保证第一个 chunk 完全可能只是一个换行符或者半个{。于是模型的 JSON 曾被当成纯文本在气泡里一个字一个字地刷出来刷到一半页面才反应过来不是人话。修法是把判断推迟到去掉前导空白之后的首个有效字符”并且在此之前把所有碎片先攒进预备缓冲。这两个坑都不深但都有共性都出在我以为流的边界和内容的边界是一致的这件事上。流的边界由网络决定内容的边界由语法决定二者经常错位。还有一个容易被忽略的细节值得记一笔把上一轮 AI 的输出原样塞回请求消息里。messages数组里除了用户消息我还要把模型上一次返回的那整段 JSON 以assistant身份原样递回去。这么做看着笨实际上省了无数心。模型看到自己上一轮交的答卷就知道前面的题目数字是哪些避免重复出题那条规则才真正有对象可依。要是只顾着往 history 里存用户说过的话、把 AI 的回复丢一边多轮对话就会陷入失忆状态孩子上一秒刚练过 7 8下一秒它又出 7 8。这个细节我不会写进任何文档但它比好几个花哨的功能都实在。另外 history 我留了个 20 条的上限防止聊天聊长了把上下文撑爆反正对练口算这种短对话二十来条历史绰绰有余。八、界面的样子说完了逻辑简单说说脸面。整个页面是一套粉蓝渐变的糖果色圆角拉满字体偏大。向下的布局是这样的顶部一条 header放着项目名数字加减法、副标题小算算陪你一起练口算右上角一个出题快捷按钮中间是滚动的对话区底部一条输入栏。用户消息在右侧蓝底白字AI 消息在左侧白底紫边。出题时AI 气泡下面跟一张题目卡上方一个小标签写着加法题或减法题减法标签换蓝色中间是超大的算式比如7 5 ?字号给到 34px隔老远就能看见再往下是难度说明和一个答案输入框加提交答案按钮。点提交绿色或红色背景的结果条弹出来如果答对就是答对啦你真棒答错会亮出正确答案下面竖起一张解题思路清单。AI 的思考过程被我收进了一个默认折叠的details面板标题是看看小算算在想什么。这个设计算是我的私心一来不让推演过程挤占版面二来小孩子如果好奇点开还能看看 AI 是怎么心里算的。不过说实话thinking_budget推演出来的东西跟我儿子口算时的脑回路完全是两码事给他看更多的是一种玩具效果。移动端我也没有放过。页面用了100dvh撑满手机视口顶部和底部栏做了毛玻璃效果聊天区在中间独立滚动。需要动手敲答案的输入框在手机上把 inputmode 设成了 numeric弹出的是纯数字键盘省得小朋友在字母键盘里手忙脚乱。字号在小屏上统一降了一档题面算式在窄屏下依然能一行放下。这些细节谈不上什么高级技巧但对一个要给小孩用的工具来说值当花这点时间。毕竟在大多数家庭里供小朋友用的屏幕是平板而不是台式机。九、测试先是 curl再是 Node最后让浏览器自己跑这个项目我分了三层来测。第一层是 curl 冒烟。有没有报错、流式长什么样、[DONE] 怎么收尾全在第一层看明白。这一步本来是最快的但也是最有价值的一步因为所有能不能用的怀疑都在这里解决。第二层是一个 Node 脚本。我从 app.js 里用正则把系统提示词原样抠出来喂给接口跑五类代表性的用户输入检查每一条返回都能被parseJson解析成合法的对象、type属于三种之一、有题目时text和answer是齐全的。这一层其实就是在替前端把模型靠不靠谱这个问题测透。脚本连续跑下来五类用例全过我信心就足了大半。第三层是 Playwright 浏览器端到端。让浏览器真的打开页面输入出一道加法题等题目卡渲染出来脚本自己从算式里算出答案填进去点提交断言出现答对啦再点下一题。层叠的截图一张张存下来我躺沙发上翻着看跟验收别人的产品一样。前面说的那个下一题按钮消失的 bug就是在这一层现的原形。顺带一提前端渲染那一层我还真确认过一次样式问题。截图里所有 emoji 都变成了空格占位一开始以为是代码写挂了仔细查了才知道是测试环境缺 emoji 字体真实浏览器没有这个问题。查这个破字体花了我半小时算是一个额外教训无头浏览器的截图不等价于真实用户的屏幕。十、安全那点事密钥终究是个妥协必须承认一件事这个项目的接口密钥目前写在config.js里跟着仓库一起走。纯前端项目没有后端这是最省事的做法但也意味着拿到仓库源码的人可以拿这个 Key 去调接口。我把边界划得很清楚这是一个演示和自用性质的玩具不是要上架的产品。所以在 README 里我用一整个章节写明了这件事提醒任何想在正式环境用这个东西的人务必要加一层后端代理把密钥藏到服务端前端永远只跟自己的服务说话。写代码这件事省事的选项和安全的选项往往不是同一个选哪个取决于这代码是给谁用的。十一、收尾与感想整个项目从想法到推上 GitCode前后用了一整个周末的零碎时间。功能清单并不长AI 出题、前端判题、分步讲解、自由对话外加一个可折叠的思考面板。回头看这个项目让我最意外的地方不是流式解析写得多顺写到一半你肯定明白它就是一层窗户纸而是讲题这个我原本觉得最难的需求反而被大模型一句话解决得最干净。出题、判题、甚至布局都是我熟悉的体力活只有讲解那一环是让模型用孩子的语言重新组织思路这恰恰是我这个大人做不到、也不想假装能做到的事。技术没有把教这件事复杂化反而把一个家庭里最稀缺的事——耐心变成了可以无限复制的东西。后续想加的还排着队答题正确率的统计和成就徽章让 AI 把算式朗读出来的语音按钮实在不行还可以让 AI 出几道带图的应用题。但那些都属于锦上添花眼下这个版本已经能让我家小朋友自己点开网页练上十分钟错题还有个人给他讲。这就值回票价了。写到这里我想起一个挺有画面感的瞬间。项目完工那天晚上我把页面投到平板上孩子第一次自己点了出一题屏幕上跳出一张粉色的算式卡片。他盯着看了三秒抬头问我这个题是真的吗它会不会出我没有学过的“我说你试试看。他填了个答案点提交跳出一句答对啦你真棒”眼睛一下就亮了。那一刻我意识到我做的这个小东西技术含量大概只够在部门内部分享会上讲十分钟但它确确实实解决了我家里一个反复发生了半年多的真实问题。一个小工具的意义往往不在它的代码量而在它恰好卡进生活的某个缺口里。如果这篇手记能让你也动起手来做一个小小的人工智能玩具那我的目的就达到了。不要把大模型想得多高不可攀在它背后出题、判题、讲解说到底还是你说了算。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进