AI编程导师:80名学生仅6人毕业,编程教育被忽视的四个前提是什么
我花了一年时间,看着学生一个接一个地消失。当时我经营着一家编程培训机构,学生们会进入直播课堂,积极参与,然后突然在上课途中掉线。后来我逐个跟进才发现,几乎没有人是主动退出的。他们的网络连接根本撑不到一节课结束。在一批报名的 80 名学生中,最终只有 6 人毕业。 这段经历让我意识到,大多数编程教育平台都建立在四个默认前
我花了一年时间,眼看着学生一个接一个消失。那时候我经营着一家编程培训机构,学生进直播课堂时还挺积极,可上着上着就突然掉线。后来我挨个去问,才发现几乎没人主动退出——他们的网络根本撑不到一节课结束。报名的80名学生里,最后只有6人毕业。
这段经历让我看清了一件事:大多数编程教育平台都建立在四个默认前提上。这些前提太普遍,大家几乎忘了它们只是假设——学习者得有笔记本电脑、有宽带、能轻松读英文,而且付得起订阅费,或者所在机构已经替他付了。
我做的Efiwe平台,就是冲着推翻这些假设去的。它在智能手机浏览器里直接跑,只需完成一次初始设置,之后就能离线使用。学生提交的代码也在本地执行,首次加载完成后不再消耗移动数据。目前,Efiwe已被160个国家的22,000多名学习者使用,尼日利亚的拉各斯州、奥贡州以及印度北阿坎德邦的学校都开展了有组织的试点。
这篇文章不谈教学方法,只谈工程实践,尤其是那些出过问题的地方。真正棘手的并不是模型质量,而是存储、配额、冷启动和失败路径的设计。这些问题也不只出现在教育产品里。只要产品面向的是设备性能有限、网络时断时续的用户,就很可能撞上同样的挑战。
为什么选择PWA,而不是原生应用
我们首先要定的是产品怎么分发。开发一款原生Android应用看起来是最自然的路子,但最终没走,原因有三个。
头一个是安装门槛。哪怕还没下载任何课程内容,从应用商店装一个APK,往往也要吃掉几十MB流量,而学生可能正精打细算地用自己的流量套餐。渐进式Web应用(PWA)让学生点开就能学,缓存可以在后台一点点完成,流量开销也就分摊到了学习过程里。
再就是更新频率。我们一直在发新版本。用Service Worker,新版本在用户下次访问时就能生效;应用商店发布新版本做不到这一点。在那些用户为了省流量而推迟更新应用的市场里,可能有相当一部分人还在跑几个月前就已经修过问题的旧代码。
第三,Web平台让我们用一套运行时就把代码执行的问题解决了。学生需要能写Python、SQL、JavaScript和HTML,还要立刻看到运行结果,而且整个过程不能依赖服务器。浏览器里的WebAssembly让我们一次实现,各平台都能跑。
我们也想过用原生应用外壳包一个WebView。这样同样能用Web运行时,如果加载的是远程内容,更新问题也能解决。可安装门槛还是绕不过去:学生仍然得先下载APK才能开始学,而这笔流量可能正是他们舍不得花的。对一个从根本上就是为了降低学习门槛而设计的产品来说,这一点已经足够让我们拍板了。
这个选择也有代价:我们必须接受浏览器的存储机制,包括它的配额限制。而我们在生产环境里遇到的大部分麻烦,恰恰都来自这里。
不依赖服务器执行代码
通常,评判一道编程题的答案,得把学生提交的代码发到服务器,在沙箱里执行,再把判定结果返回给学生。这意味着每试一次都要经历一次网络往返。在尼日利亚伊杰布奥德的一间教室里,网络连接并不稳定,而学生往往要试十到十二次才能解出一道题。这种模式根本行不通。
所以,我们让代码直接在设备上执行。SQL用的是sql.js,也就是编译为WebAssembly的SQLite;Python用的是Pyodide,也就是编译为WebAssembly的CPython。两者的体积都不小,初始化也都很慢。处理不当的话,它们都会阻塞主线程,让页面卡住。
最后我们定下一条规则:任何运行时都不能在主线程初始化,也不能在真正需要之前初始化。学生选择课程时,才会按需启动Worker,而且挑战页面的首次渲染绝不能等运行时初始化完成。下面这段代码展示了这种做法:
let workerPromise = null;export function getRuntime(kind) { if (!workerPromise) { workerPromise = new Promise((resolve, reject) => { const worker = new Worker('/workers/runtime.js', { type: 'module' }); const timer = setTimeout( () => reject(new Error('runtime-init-timeout')), 45_000 ); worker.addEventListener('message', function onReady(e) { if (e.data?.type !== 'ready') return; clearTimeout(timer); worker.removeEventListener('message', onReady); resolve(worker); }); worker.postMessage({ type: 'init', kind }); }); } return workerPromise;}
初始化消息方面,Worker 里另加了防护:同一条消息第二次到达时,不会被重复处理。模块文件同样走缓存优先的路子,设备哪怕已经离线一周,学生再打开应用,运行时照样能起来。
let runtime = null;self.addEventListener('message', async (e) => { const { type, kind, code, fixture } = e.data; if (type === 'init') { if (!runtime) runtime = await boot(kind); self.postMessage({ type: 'ready' }); return; } if (type === 'run') { try { const result = await runtime.exec(code, fixture); self.postMessage({ type: 'result', result }); } catch (err) { self.postMessage({ type: 'result', error: String(err?.message ?? err) }); } }});
上面这段代码还有一处差异,需要展开讲讲。普通应用里,执行时抛出异常,多半说明有 Bug;可放到这里,多数情况下这本来就是预期内的结果——学生都是初学者,学习过程中犯错再正常不过。所以整条代码执行链路,每一层都得把失败当成常规情形接住。
SQL 题目的判定不看输出字符串本身,看的是结果的结构和内容。只要题目允许,学生用不同格式、别名或者列顺序,查询写对了,照样能过。
export function gradeSql(actual, expected, opts = {}) { if (!actual || !actual.length) return { pass: false, reason: 'empty' }; const [got] = actual; const cols = got.columns.map((c) => c.toLowerCase()); const want = expected.columns.map((c) => c.toLowerCase()); if (!opts.orderedColumns) { cols.sort(); want.sort(); } if (cols.join('|') !== want.join('|')) { return { pass: false, reason: 'columns', got: cols, want }; } const norm = (rows) => rows.map((r) => r.map(String).join('\u0001')).sort().join('\u0002'); const rowsMatch = opts.orderMatters ? JSON.stringify(got.values) === JSON.stringify(expected.values) : norm(got.values) === norm(expected.values); return rowsMatch ? { pass: true } : { pass: false, reason: 'rows' };}
这里的 reason这些字段不是摆设。后面的反馈系统要靠它做输入,才能对学生说:列选对了,筛选条件有问题——而不是只丢一句答案错了。
持久化存储:为什么localStorage不够用了
Efiwe 的内容覆盖 246 种语言。翻译好的编程题文本会存在设备缓存里,选约鲁巴语或印地语的学生,不必每次学习都重新下载同样的内容,断网之后翻译也照样能用。
最初,我们用localStorage来保存这些缓存。后来证明,这是个错误的选择,而且同时暴露了两个问题。
头一个问题是容量。localStorage 一般给每个源设约 5MB 的上限,具体数字随浏览器和平台不同。一门有数百道编程题的课程,就算只缓存几种语言的翻译,也很容易顶破这条线。一旦超限,写入操作就会抛出QuotaExceededError。如果没有对每次写入都做好异常处理,错误就可能在意想不到的地方冒出来。
第二个麻烦更棘手,也更难察觉:localStorage 是同步执行的,每一次读写都会把主线程卡住。换到一台中端笔记本上,这点开销或许只有几毫秒,根本感知不到;可放到一部入门级 Android 手机上,闪存控制器本身就慢,CPU 又已经跑满,启动时来一次稍大的同步读取,卡顿就会很明显——偏偏这种设备最经不起卡。团队此前花大力气压缩 JavaScript 代码体积,结果却在存储层里留下了一个主线程阻塞点。也正是这个原因,项目后来改用了 IndexedDB:它是异步的,配额一般按可用磁盘空间的比例分配,而不是固定几 MB,结构化数据还能直接存,省掉了 JSON 序列化和反序列化。
选 IndexedDB 本身不难,难的是让老用户平滑过渡、一条数据都不丢。已经上线的用户数据还压在旧存储里,其中不少人会连着离线好几天,他们什么时候跑上新代码根本没法预判。如果直接切换,缓存的翻译和没做完的学习进度就可能无声无息地消失。团队因此安排了一段双重读取的过渡期:先查新存储,找不到再读一次旧存储;读到之后把数据迁过去,然后删掉旧的键。
import { get as idbGet, set as idbSet } from './idb.js';const LEGACY_PREFIX = 'efiwe.tr.';export async function readTranslation(key) { const fresh = await idbGet('translations', key); if (fresh !== undefined) return fresh; let legacy = null; try { const raw = localStorage.getItem(LEGACY_PREFIX + key); legacy = raw ? JSON.parse(raw) : null; } catch { legacy = null; } if (legacy === null) return undefined; try { await idbSet('translations', key, legacy); localStorage.removeItem(LEGACY_PREFIX + key); } catch { } return legacy;}
这段函数里有两个细节,基本就是这次迁移换来的全部经验。其一,读取旧数据的那一步被包进了异常处理,原因是上一次会话如果中途断掉,localStorage 里的数据可能只写了一半,这时JSON.parse就会抛异常。一次缓存解析出错把整个页面搞崩,是团队绝不能接受的。其二,迁移写入另外单独包了一层异常处理:设备存储满了的时候,读可能成功,写却会失败。遇到这种情况,正确的做法仍是把内容先交给学生,迁移留到下次再试。
存储配额得当成必须考虑的故障场景
如果这篇文章只能让其他工程师记住一件事,那最该记住的是下面这次事故。
在尼日利亚试点期间,有老师反馈:部分学生在手机上注册完账户后,学习页面一直显示不出来。账户创建成功,登录也没问题,可课程就是加载不了,屏幕一片空白。
团队一开始怀疑是低端 Android 设备的硬件兼容性出了岔子。这个猜测听着挺合理,结果却是错的。问题集中在便宜设备上,排查时间也因此白费了不少。说到底,那只是相关性,不是因果关系:便宜的机器往往存储容量更小,而容量越小,越容易被塞满。
真正的原因,藏在课程启动流程里的一段await代码中。启动时,程序会先把初始课程清单写进缓存,再往下走。可设备一旦没有剩余空间,这次写入就可能失败。这个错误没有被捕获,Promise 链在调用渲染之前就断了。学生那边看不到任何报错,因为程序并没有以 UI 能察觉的方式崩溃,只是课程的渲染函数压根没被调用。
原来的代码大致如下:
async function startCourse(courseId) { const manifest = await fetchManifest(courseId); await cacheManifest(courseId, manifest); renderCourse(manifest); }
改完之后是这样:
async function startCourse(courseId) { const manifest = await fetchManifest(courseId); cacheManifest(courseId, manifest).catch((err) => { reportNonFatal('manifest-cache-failed', err); setOfflineAvailability(courseId, false); }); renderCourse(manifest);}
现在,哪怕数据存不进设备,课程也能正常启动。离线功能会打折扣,团队也会明确告诉学生,但不会因此拦住他们继续学。修好之后,团队在各种存储条件下做了验证,其中一台设备被故意填满存储空间,确认问题解决才关掉这次故障记录。从收到反馈到修复并验证完毕,全程都在当天完成。
后来团队总结出一条通用规则:启动流程里的持久化写入,必须发起后不等待结果,并且明确捕获异常,绝不能await等写入完成再往下走。要是页面渲染非得等写入成功才能继续,那用户只要把手机存储塞满,就可能撞上白屏。
此外还加了一道预检查,让系统能主动降级功能,而不是等写入失败了才发现问题。
export async function storageBudget({ if (!navigator.storage?.estimate) { return { known: false, freeBytes: Infinity }; } const { quota = 0, usage = 0 } = await navigator.storage.estimate(); return { known: true, freeBytes: Math.max(0, quota - usage), quota, usage };}export async function canCacheCourse(estimatedBytes) { const { known, freeBytes } = await storageBudget(); if (!known) return true; return freeBytes > estimatedBytes * 1.25; }
出于隐私保护方面的考虑,navigator.storage.estimate()返回的数值经过填充处理,只是近似值,所以它只能拿来规划,不能当作保证。但这些信息已经够用:能帮我们判断该预缓存整门课程,还是按需加载;也足以提前提醒学生,想用离线模式,得先清出一些存储空间。
两级AI反馈机制,切换规则写得很死
Efiwe 会针对每个学生的错答给出个性化反馈。尼日利亚试点中,这是实地调研评分最高的一项功能,满分 5 分,平均拿到 4.3 分,10 位老师里有 9 位打了 4 分或 5 分。
可这项功能与离线优先的冲突也最大。质量最好的反馈要靠 API 调用云端模型,而产品最基本的设计前提,偏偏是网络可能根本不存在。
最终定下来的是两级架构,两级怎么切换也写清楚了。网络通的时候走云端模型,超时限制卡得很死;没有网络就落到能力更弱的端侧方案上,也就是把确定性分析和本地模型搭配起来用。第二级随时都能跑,而且对事实的判断不会出错——这部分交给确定性分析来完成。
const HOSTED_TIMEOUT_MS = 4000;export async function getFeedback(submission, challenge) { const local = analyseLocally(submission, challenge); if (!navigator.onLine) return { ...local, tier: 'device' }; try { const hosted = await withTimeout( requestHostedFeedback(submission, challenge, local), HOSTED_TIMEOUT_MS ); return { ...hosted, tier: 'hosted' }; } catch { return { ...local, tier: 'device' }; }}function withTimeout(promise, ms) { return Promise.race([ promise, new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), ms)), ]);}
有三个设计细节,我们花了比预想更长的时间才真正想明白。
本地分析总是先执行,而且无条件执行,不是等云端失败后才作为备用方案启动。它运行开销很低,结果也是确定的,同时还会作为上下文传递给云端请求。这意味着云端模型面对的是一份已经完成初步诊断的代码,不必从零开始分析,因此输出会更稳定,提示词也更短。
另外,我们会检查navigator.onLine但不会对它全信。这个属性只能说明设备上有没有网络接口,网络是否真能用,它证明不了。哪怕手机连的是要登录认证的 Wi-Fi,或者移动网络已经卡死,它照样可能返回true。因此,我们只把它当作快速判断离线状态的信号。真正保护学生不被长时间等待拖住的,是超时机制。
另外,反馈来自哪一级,我们会在响应里标出来。答案具体是哪条路径生成的,不会藏着。这不只是自家遥测数据的需要:能力偏弱的第二级给出的反馈,表达方式本来就不一样。
让反馈真正有助于学习
早期的反馈非常简单,基本只有对错之分。无论学生提交的是consolee.log("hi"),还是根本没有理解函数是什么,收到的反馈都差不多:“这里错了,应该改成这样。”
试点里有位老师把这个漏洞说得很准。学生是收到了纠错提示,可要是没有她坐在旁边带着改,学生压根不知道怎么动手。这跟我们想做一个能规模化帮到学生的编程导师,完全是两回事。
做法是先量一量学生提交的答案离正确答案差多远,再据此决定给哪种反馈。只差一点,就点出具体是哪个字符出了错;错在代码结构上,就把背后的概念讲清楚。
export function proximityHint(submitted, expected) { const d = levenshtein(submitted, expected); if (d === 0) return null; if (d > 2 || Math.abs(submitted.length - expected.length) > 2) { return null; } const diff = firstDivergence(submitted, expected); if (diff.kind === 'extra') { return `快答对了!你多写了一个 "${diff.char}"。删掉它就对了。`; } if (diff.kind === 'missing') { return `快答对了!你漏写了字母 "${diff.char}"。加上它就对了。`; } return `快答对了!检查一下第 ${diff.index + 1} 个字符。`;}
团队内部我常打一个比方:两个老师改同一份作业。经验少的那个,在错答案旁边打个红叉,再把正确答案写上;经验多的那个,会精确圈出错在哪儿,再针对要改的地方写提示。这次调整之后,我们的反馈从前者变成了后者。整件事全靠确定性逻辑跑,也就是说离线时效果一点不打折。
多人共用设备,必须从架构层面考虑
尼日利亚的试点里,10 位老师中有 8 位觉得,学生手上没有自己的设备是最大的拦路虎。有的手机是兄弟姐妹共用的,有的从老师那儿借,还有的是一节课上一组学生轮着用。
当时,我们的身份认证模型默认一台设备对应一名学生。两名学生共用一部手机,就必须不断退出和重新登录。网络缓慢时,每次切换都可能浪费一分钟的课堂时间。更糟的是,学生还可能因此丢失尚未同步到服务器的本地学习进度。
所以说,账户切换算不上什么额外加分项,而是实际使用环境逼出来的必备能力。如今一台设备最多能存 20 个用户档案,点一下就能换,不用彻底退出登录。落到架构上,就是每个本地存储空间都得按用户档案单独划命名空间,让学习进度、翻译缓存和运行时状态各管各的,不再全局共用。
const scoped = (profileId, store) => `${store}::${profileId}`;export const progressStore = (profileId) => ({ get: (k) => idbGet(scoped(profileId, 'progress'), k), set: (k, v) => idbSet(scoped(profileId, 'progress'), k, v),});
产品要卖给多人共用一台设备的市场,这套机制就得早点做进去。等上线之后再回头拆一个全局共享的本地存储,代价比第一天就按命名空间隔离高得多。
实地数据改变了什么
前面提到的大多数经验,都来自两次实际部署。印度北阿坎德邦有四家机构,用受统一管理的设备运行我们的平台。尼日利亚的拉各斯州和奥贡州,来自十所学校的十位老师做了为期八周的试点,几乎完全靠学生自己的手机。
从这两轮试点的差异里,能看出哪些麻烦出在平台自身,哪些是使用环境造成的。对工程路线来说,这恰恰是试点最值钱的部分。尼日利亚那边,离线可靠性的平均分是4.0分(满分5分),低分全部集中在一类情形:第一次缓存还没做完,网就断了。问题不在离线能力本身靠不住,而在首次运行时的任务顺序安排有误。先缓存离线运行必需的最小内容,再处理其余可选项,这个在代码层面就能改掉。学生有没有设备可用,则不是写代码能解决的。硬把技术当成万能药,只会白白耗掉工程时间。
最叫人泄气的发现跟语言有关。平台支持246种语言,这原本就是我们做它的重要理由之一。可试点里,10位老师中有4位压根没碰过本地语言界面,还有1位试过,却不知道该怎么用。况且,这些老师在入门培训时都看过这项功能的演示。症结在于语言切换按钮搁在页面底部。学生正忙着一道道完成编程挑战,没有理由特意滚到最下面去找。功能确实有,有文档,也演示过,实际用起来却几乎看不见。
功能容不容易被发现,跟好不好用是两码事。用户得事先知道去哪儿找才能发现某个功能,那对多数人来说,这功能跟没有差不多。我们正把语言切换控件挪到学生学习时自然会看到的地方。更大的收获是,现在任何功能发布前都要先过一道检查:不问“告诉用户位置后他能不能找到”,改问“用户按自己的实际操作路径走,会不会自然碰见它”。
尚未解决的问题
还有三个问题没搞定。与其编一个皆大欢喜的收尾,我更想把它们照实讲出来。
在最弱的设备上,WebAssembly 运行时的冷启动速度还是没达到我的预期。模块非得从缓存而不是网络加载时,流式编译也只能解决一部分。
端侧模型的质量,才是离线反馈能力真正的天花板。眼下确定性分析扛下的活儿仍超出我的预想。它能表现得还行,很大程度是因为编程题有可验证的正确答案。换到答案不那么明确的领域,就没有这种兜底手段了。
存储依旧棘手。配额数据是填充过的,缓存清理机制不够透明,而且不同浏览器对persist()持久化存储请求的支持也参差不齐。文中这些防御性设计,不过是在补 Web 平台自身的短板,并没有真正解决问题。
可这些都不足以成为放弃这条开发路子的理由。另一条路是继续假定每个学生都有笔记本和宽带,那会把大多数真正需要这类产品的人挡在门外。这条路工程上确实更难走,但能服务到的人也因此多了数十亿。