码道传习录:用大模型让"两小儿辩日"穿越千年 —— 我的首个零依赖 AI 对话项目开发全记录

请添加图片描述
在这里插入图片描述

一、写在前面:一个来自两千年前的灵魂提问

“孔子东游,见两小儿辩斗,问其故。一儿曰:'我以日始出时去人近,而日中时远也。'一儿曰:‘我以日初出远,而日中时近也。’”

这段出自《列子·汤问》的文字,大概是每个中国人在语文课本里最早接触到的"科学启蒙"。两个孩子在村口争论太阳究竟什么时候离我们更近,一个说清晨近——因为"日初出大如车盖,及日中则如盘盂",远者小而近者大,此为"近大远小"的视觉论据;另一个说中午近——因为"日初出沧沧凉凉,及其日中如探汤",近者热而远者凉,此为"近热远凉"的触觉论据。两小儿各有各的道理,各执一词,谁也不肯让步,最后把满腹经纶的孔夫子都问住了,两个小孩得意地笑道:“孰为汝多知乎?”

这个故事之所以流传千年,不是因为它的答案,恰恰是因为它没有标准答案。它把"眼见未必为实、经验未必可靠"的哲学困境,用一个极富画面感的场景摊开在我们面前。真正的问题从来不是"太阳什么时候近",而是"我们如何知道自己知道"——这种思辨精神,正是中国古代哲学里最动人的部分。

很久以来,我一直想把这个场景"做活"。不是读它、讲它,而是让两个小孩真的在我面前吵起来。我设想过动画、设想过小游戏,但总觉得缺了点什么。直到我接触到大模型 API,一个念头突然清晰起来:如果让大模型扮演小儿甲、小儿乙和孔子,让一场跨越两千年的辩论在我眼前实时展开,那会是什么体验?

于是就有了这个项目——两小儿辩日 · AI 对话。本文将从项目缘起、技术选型、核心设计、踩坑实录到最终交付,完整记录这个项目的开发全过程。希望这篇"码道传习录",能为同样想用大模型做点有意思事情的朋友提供一些参考。

二、项目缘起:为什么偏偏是"两小儿辩日"

在做任何一个项目之前,我都习惯先问自己三个问题:它有趣吗?它有难度吗?它能学到东西吗?"两小儿辩日"恰好同时满足了这三个条件。

第一,它天然适合"多角色对话"。 这个故事里有四个天然的角色:小儿甲(主视觉论据)、小儿乙(主触觉论据)、孔子(持中立态度、善于发问)、旁白(交代场景推进节奏)。多角色辩论天然适合大模型生成,因为大模型最擅长的就是"扮演",给它一个清晰的性格设定和立场,它就能产出认知一致、风格统一的台词。四个角色之间又有明确的对立关系(甲乙针锋相对)、调和关系(孔子往返调和)、以及叙事关系(旁白贯穿),这种结构化的角色网络,正是检验提示词工程功力的最佳试验田。

第二,它自带"科学思辨"的话题纵深。 两小儿辩日的背后,其实是物理学、天文学、生理学、心理学的多重交集。"日初出大如车盖"在今天对应的解释是大气折射与视错觉(明尼苏达错觉——太阳在低空时与地面参照物对比产生"更大"的错觉);"日初出沧沧凉凉"则与太阳高度角、地表热容、大气吸收路径有关。这意味着辩论不会干巴巴的——模型有能力把古人的"朴素直觉"和现代科学对照起来,让对话既有古韵又有知识密度。

第三,它是"经典文本的现代化转译"。 用当时最前沿的技术(大模型)去演绎最古老的文本(先秦典籍),这种"古今碰撞"本身就充满传播张力。同时项目立项之初就想好了它的定位:一个可以直接在浏览器里打开、零配置、零依赖的纯静态网页,适合放在任何静态托管平台上,也适合作为"AI 应用入门"的教学范例。

想清楚了这三点,"做"的冲动就被"怎么做"的规划取代了。

三、技术选型:把复杂留在代码里,把简单留给用户

项目立项时给自己立了三条军规:

军规一:零构建、零依赖。 用户打开一个 HTML 文件,或者把文件夹丢进任意静态托管平台(GitHub Pages、AtomGit Pages、云 OSS、Nginx 都行),项目就能跑起来。因此技术栈被严格锁定在 HTML5 + CSS3 + 原生 JavaScript(ES6+),不引入 Vue、React、jQuery,不使用 npm、webpack、vite。这样做的好处是显而易见的:部署零门槛、维护零成本、教学零干扰——项目里每一行代码都是"看得懂"的。

军规二:AI 能力走标准接口、留出自由度。 对接的是 OpenAI 兼容的 Chat Completions 接口(https://api-ai.gitcode.com/v1/chat/completions),模型选用 deepseek-ai/DeepSeek-V4-Flash。选这个模型是权衡后的结果:它综合能力强、输出稳定、响应速度快,而且支持流式输出与思考预算(thinking_budget),非常适合做"有来有回"的实时对话体验。同时因为接口标准,未来想换模型只需要改一个字符串——这种用标准换取自由的设计,是工程上性价比最高的决策。

军规三:体验优先于酷炫。 一切动画、特效都服务于"读对话"这个核心场景,绝不让视觉喧宾夺主。读者进入页面三秒钟内应该明白:这是两个小孩在吵架,我可以输入任何辩题让他们吵给我看。

在开始写代码之前,我手上还有一份"参考答案"——一段 Python 的流式调用示例代码。它展示了如何用 requests 库以 SSE(Server-Sent Events)方式请求大模型并逐行消费返回。我的第一个技术任务,就是把这段 Python 代码"翻译"成浏览器环境里的 JavaScript,并在此基础上做大幅增强。这段从"示例"到"产品"的改造过程,是本文最值得细读的部分。

四、从 Python 到 JavaScript:一次意义不止于"翻译"的重写

原始的 Python 示例代码大概长这样(为便于叙述做简化):

def query(payload):
    response = requests.post(API_URL, headers=headers, json=payload, stream=True)
    for line in response.iter_lines():
        if not line.startswith(b"data:"):
            continue
        if line.strip() == b"data:[DONE]":
            return
        yield json.loads(line.decode("utf-8").lstrip("data:").rstrip("/n"))

这段代码的关键在于两点:stream=True 让 requests 返回一个惰性迭代的响应流;然后逐行读取,只处理以 data: 开头、且不是 [DONE] 终结符的行,把剩余部分解析成 JSON,每次拿到一个增量块就 yield 出去让调用方消费。这是标准的 SSE 消费范式,清晰、简洁、教科书级别。

但把它搬到浏览器,必须面对几个根本性的差异:

差异一:没有"阻塞式"的逐行读取。 Python 的 iter_lines() 是同步阻塞的,浏览器里没有这种东西。我必须改用一个"异步增量缓冲 + 行切分器"的模式:用 fetch 拿到 response.body,再通过 response.body.getReader() 获得一个 ReadableStream 读取器,reader.read() 每次返回一个 Uint8Array 字节块。问题是:一个 SSE 的"数据行"可能被拆到两个字节块里,也可能一个字节块里包含多个完整数据行。所以需要一个持久化的缓冲区:

const reader = resp.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";

while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true }); // 流式解码,避免中文乱码
    let idx;
    while ((idx = buffer.indexOf("\n")) >= 0) {        // 按换行切分
        const line = buffer.slice(0, idx).trim();
        buffer = buffer.slice(idx + 1);
        if (line.startsWith("data:")) handlePayload(line.slice(5).trim());
    }
}

这里有个隐藏的坑:必须用 decoder.decode(value, { stream: true }) 做流式解码,而不是把每次的字节块单独 decode。否则一旦某个中文字符被 UTF-8 编码成多字节后恰好被切分到两个块里,就会在边界处解出乱码(�)。像"小儿""辩日"这样的词,都可能被拦腰斩断。这是所有 SSE 解析器都必须处理的细节,也是"翻译"和"重写"的区别所在——翻译只关心语法对应,重写必须关心语义正确。

差异二:错误处理从"要不要"变成"必须"。 Python 示例代码在脚本里跑,出错最多是抛异常退出。但浏览器里的代码要面对 CORS 拦截、网络抖动、429 限流、401 鉴权、断流重连等一堆现实问题。我的实现里对每个环节都做了防御:非 2xx 响应读取出错文本并展示;AbortController 让用户随时可以"停止辩论";流中途返回空内容时兜底清空缓冲。前端不是实验室里的一次性脚本,它必须永远处于"即使出错了界面也不会坏"的状态。

差异三:从"打印"到"渲染"。 示例代码拿到 chunk 后只是 print(chunk["choices"])。而在网页里,每一个增量都要落到 DOM 上。这就引出了整个项目最核心的设计问题——模型输出的数据格式,决定了前端渲染的优雅程度。我们先按下不表,下一节专门展开。

这段"翻译",让我得到的最大认知是:参考代码是地图,不是领土。 它标出了方向(Stream 流怎么取、怎么切、怎么停),但真正的领土——错误处理、边界情况、用户体验——必须自己一寸一寸地走一遍。

五、核心创新:用"提示词即协议"设计结构化输出

写这个项目时,我反复思考一个问题:大模型输出的是"一段文本",而前端想渲染的是"一句带角色、情绪、动作的台词"。中间的这个鸿沟,怎么填?

方案可以有很多:

  • 方案 A:让模型输出整段 Markdown。 前端拿到后就当纯文本展示。实现最简单,但信息密度低,一个气泡里混着多个角色的台词,情绪动作全丢失,阅读体验糟糕。
  • 方案 B:让模型输出 JSON 数组。 结构完整,但有一个致命伤——JSON 在流式传输中是"最后一行才闭合"的。在 ] 出现之前,前端永远无法确定当前对象是否完整,只能把所有增量攒到最后一次性解析。这意味着用户要盯着一个空荡荡的加载动画等十几秒,流式体验名存实亡。
  • 方案 C:发挥想象力——让模型直接输出 NDJSON(Newline Delimited JSON)。

方案 C 就是最终答案。所谓 NDJSON,就是每一行一个独立、完整、自洽的 JSON 对象,行与行之间用换行符分隔。模型每说完一句话,就吐出一行完整的 JSON;前端每收到一个换行符,就知道"这一句说完了,可以渲染了"。流式传输的粒度,和渲染的粒度,完全对齐。

于是,系统提示词的核心约束变成了这样一段"协议声明":

【输出要求 —— 极其重要】
1. 每一句台词单独输出一行 JSON 对象(NDJSON 格式),不要输出任何 JSON 之外的文字。
2. 字段固定为:{"speaker":"角色","content":"台词","emotion":"情绪","action":"动作","scene":"场景"}
3. speaker 只能取:小儿甲 / 小儿乙 / 孔子 / 旁白
4. scene 仅"旁白"需要填写,其他角色一律填空字符串。
5. 每次回复输出 6-10 行,构成完整一轮辩论,最后以"旁白"小结收尾。

配套的还有完整的角色设定——小儿甲"善用视觉论据,日初出大如车盖……此不为远者小而近者大乎",小儿乙"善用触觉论据,日初出沧沧凉凉……此不为近者热而远者凉乎",孔子"知之为知之,不知为不知,是知也"——以及明确的交互要求:“小儿甲提出论据后,小儿乙必须正面反驳,孔子不时点评发问,最后两小儿笑曰收场。”

这一套组合拳的效果是惊人的。模型输出的结构稳定性远超预期,大多数轮次都能给出格式规范、角色不串、逻辑自洽的辩论。当流式数据到达前端时,跑的是这样一段增量解析逻辑:

streamingBuffer += content;                    // 增量追加
let idx;
while ((idx = streamingBuffer.indexOf("\n")) >= 0) {
    const line = streamingBuffer.slice(0, idx).trim();
    streamingBuffer = streamingBuffer.slice(idx + 1);
    if (!line) continue;
    try {
        const parsed = JSON.parse(line);        // 完整一行 → 渲染
        if (parsed && parsed.speaker && parsed.content) renderLine(parsed);
    } catch (e) {
        appendStreamText(line + "\n");          // 不完整 → 暂作纯文本
    }
}

一行的 JSON 解析成功就直接渲染成一条带头像、气泡、情绪标签、动作标签的对话;解析失败的残行不会让界面崩溃,而是降级为纯文本流式展示——兜底逻辑保证了任何时候对话都不会中断。流结束时再把缓冲区里残留的最后一小节flush掉,双保险。

这里我最想分享的心得是:当你需要和大模型交换结构化数据时,与其把解析难度压给后处理(再强大的正则也防不住模型的自由发挥),不如从源头约束输出格式。 NDJSON 是"流式友好"与"结构化"两种诉求的最优交集。这个思路后来也被我用在了不少别的 AI 项目里,屡试不爽。

六、界面设计:让古代辩论活在"水墨画卷"里

如果说数据层的关键词是"协议",那视觉层的关键词就是"语境"。

"两小儿辩日"是一个非常东方的场景,如果用现代科技感的暗色界面来做,会失去味道。我定下的视觉基调是纸本水墨:宣纸色的暖底、朱砂红与黛青两种主色、仿古的排版字体、手绘感的远山与晨昏双日。

布局上遵循"内容即重点"的原则:

  • 顶部标题区:书法感的标题与"孔子东游 · 见两小儿辩斗 · 问其故"原文副题,一进来就把用户拉进故事语境;
  • 中部对话区:这是绝对的主角。四个角色各有各的气泡形态——小儿甲(朱砂红头像)靠左,小儿乙(黛青头像)靠右,形成"隔空对垒"的视觉张力;孔子居中,自带"调和者"的居中比例;旁白居中最窄,与场景描写一起承担转场功能。每个气泡上除了台词,还用半透明的标签展示"情绪:不服气"“动作:指着太阳”“场景:日暮村口”,让辩论的"画面感"不依赖图片也能传递;
  • 底部控制区:辩题输入框、开始/停止/清空按钮、API Key 配置、状态指示。留了一个小心思——默认输入框里带着示例提示"太阳到底什么时候离我们更近?“,用户不输入任何字直接点"开始辩论”,也能得到一场精彩的默认辩论,把使用门槛降到零。

背景层用纯 CSS 绘制了一幅"会动的山水画":晨昏两轮太阳一左一右(分别代表"日始出"与"日中"两个辩题的时空背景),层层叠叠的远山用 radial-gradient 描出墨色深浅,两朵极淡的云以几十秒的周期缓缓横穿天空。所有装饰元素 pointer-events: none,绝不干扰交互;又用 prefers-reduced-motion 尊重系统"减少动态效果"的无障碍设置。

响应式上,窄屏时输入区自动改为纵向堆叠、按钮通栏,气泡最大宽度收紧,保证手机上一口气刷完一整场辩论也不会手忙脚乱。配色上朱砂红(#a63a2b)与黛青(#2b5f63)在宣纸底上的对比度足够,正文用 Noto Serif SC 衬线族,整体贯彻"古而不旧,雅而不闷"。

七、一次真实的云上测试:看见"流"在眼前流动

代码写完、页面通过本地静态服务器跑通之后,我带着 API Token 做了一次真实的云端调用。当时输入的问题是"太阳到底什么时候离我们更近?",模型在一两秒的"思考"后开始流式吐字。由于是 NDJSON 逐行输出,前端几乎在模型输出第一句话的瞬间就渲染出了第一条气泡:

小儿甲(得意地,指着初升的太阳):“日初出大如车盖,及日中则如盘盂。这不就是远者小而近者大吗?分明是清晨更近!”

小儿乙(不服气地,拉了拉同伴的袖子):“清晨凉如秋水,正午烫如热汤,这是远的热还是近的热?依我看,正午才近!”

孔子(若有所思地,抚须而笑):“两小儿之言,皆有可取之处……老夫竟不能决也。”

后续旁白收尾,四个角色轮转有序,论据与典籍互文,语言既通俗又带古意——比我预期的效果还要好。而且整个过程中,页面上没有一秒的空白等待,打字机式的逐句浮现让"一场辩论正在发生"的临场感非常强。那一刻我确认:流式 + NDJSON + 及时渲染"三位一体",是这个项目体验上的胜负手。

八、测试与验收:把"看起来对了"变成"确实对了"

纯前端项目没有复杂的单测体系,但验收标准不能含糊。我设计了覆盖核心交互链路的验收清单:

  1. 页面加载:标题精确匹配、四个角色预设齐全、初始显示一条旁白开场消息;
  2. 核心链路:输入辩题 → 点击"开始辩论"→ 进入"AI 思考中…“状态(打字指示器出现、停止按钮出现)→ 流式渲染对话 → 状态归位为"本轮辩论结束”;
  3. 异常路径:不填 API Key 时,401 错误被正确捕获并以状态文字展示,界面不崩溃、按钮状态正常复位;
  4. 控制指令:点击"停止"立即中断请求并 flush 缓冲;点击"清空"还原开场旁白;
  5. 边界行为:空输入自动补默认辩题;超长输入被 maxlength 截断;回车键等同点击开始。

验证方式采用无头浏览器(Playwright + Chromium)脚本化测试:加载页面、断言 DOM 结构、模拟点击与输入、用 MutationObserver 记录状态流转历史、捕获 console 日志与未捕获异常。最终结果五个核心验证点全部通过,console 中零未捕获异常,仅有的两条 error 一条是无害的 favicon 404(已通过内联 SVG 图标修复)、一条是预期的 401 认证失败(已被前端正确处理)。测试截图同时确认了三种状态下的界面表现与预期完全一致。

一个很重要的工程习惯是:验收清单要在写代码之前先写出来。 这让我在开发过程中始终清楚"什么叫做完",而不是凭感觉觉得"差不多了"。

九、踩坑记录:这些坑,希望你不用再踩

复盘整个开发过程,有几个坑值得单独记一笔:

坑一:中文乱码。 如前所述,UTF-8 多字节字符被切包导致乱码。解决方式是 TextDecoder 加 { stream: true } 状态化解码。排查时"偶发乱码"的迷惑性很强,因为不是每次都出现——这正是流式解码最容易忽略的经典问题。

坑二:[DONE] 的空白细节。 SSE 结束符在真实服务端返回里可能是 data: [DONE],也可能没有空格。解析时必须先 trim() 再比较,否则可能收尾不干净,页面停留在"思考中"状态出不来。这种"看起来微小、实际致命"的对空白字符的敏感性,是解析类代码的宿命。

坑三:空输入与错误状态的自愈。 最早版本里,如果流中途异常退出,sendBtn 可能一直藏在 DOM 里、状态文字停在"AI 思考中…",用户会以为页面卡死了。后来把按钮复位、状态复位逻辑统一收进 finally 块,无论成功失败异常,UI 都保证回到可用状态。把状态复位当作"铁律"而不是"最好有",是交互鲁棒性的底线。

坑四:NDJSON 的残行。 网络抖动或模型边界处的残行会导致 JSON.parse 失败。我的兜底是把残行作为纯文本渲染,宁可样式朴素,绝不让对话"卡死"。这类降级策略在真实生产环境里是必须的。

坑五:favicon 404。 浏览器自动请求 /favicon.ico 产生一条多余的 404 日志,影响了"零错误"验收标准。用一行内联 SVG data URI 声明 favicon 即修复。小事不小,干净的控制台日志是工程质量的一部分。

十、项目文件结构与阅读指南

最终交付的仓库结构如下:

liangxiaoerbianri/
├── index.html          # 页面结构(语义化标签)
├── css/
│   └── style.css       # 水墨古风样式(含背景山水画绘制)
├── js/
│   └── app.js          # 流式对话与 NDJSON 渲染核心逻辑
└── README.md           # 项目说明(API 配置、数据格式约定、FAQ)

想快速上手读代码的朋友,建议按这个顺序:先读 index.html 摸清页面骨架,再读 js/app.js 顶部注释里的系统提示词(理解"协议"),接着跳到 runDebate 函数看流式请求,最后看 renderLine 与 handleSsePayload 看渲染。整个 JS 文件不过三百行,每一个函数都可独立阅读,没有隐式全局状态带来的理解成本。

十一、展望与思考:下一个千年之问

项目虽然交付了,但在我看来它只是开了一个头。后续可以演进的想象空间还很大:

  • 多辩题模板:内置"井底之蛙"“刻舟求剑”"守株待兔"等寓言场景模板,一键切换;
  • 辩手自定义:让用户自由创建角色、设定立场与说话风格,把"两小儿辩日"泛化为"任意主题辩论赛";
  • 科学解说模式:辩论结束后由"AI 导师"客观讲解背后的大气折射、太阳高度角等科学原理,形成"辩 - 评 - 学"闭环;
  • 语音引擎:接入 TTS,让四个角色真正"开口说话",配上古琴背景音乐,把水墨动画的沉浸感再推一级;
  • 多人共辩:接入 WebSocket 与房间机制,让多个用户分别扮演小儿甲、小儿乙在线对辩,AI 扮演孔子点评。

而站在更宏观的视角回看这个项目,它其实回答了一个我一直在想的问题:大模型时代,做 AI 应用的门槛到底降到了哪里? 答案是:降到了"想法 + 提示词工程 + 一点点前端功底"。本项目没有一行后端代码、没有一个 npm 依赖、没有一份模型训练数据,却在浏览器里复现了一场千年辩论的思辨魅力。技术的手法是现代的,承载的意蕴却是古典的——这种"旧文化 × 新工具"的组合,正是我觉得 AI 创作最有味道的地方。

最后,回到《两小儿辩日》本身。两小儿笑问孔夫子"孰为汝多知乎",孔子的回答是"我不能决"——圣人不以不知为耻,这正是科学精神的起点:诚实面对无知,永远保持追问。巧合的是,大模型最令我着迷的品质也是"始终愿意继续对话"。当我把这两件事放在同一个项目里,我隐约感受到一种跨越时空的共鸣:两千年前村口的两小儿问出了当时无人能答的问题,两千年后的人们用大模型继续追问。辩题会变,追问不熄。

愿这份代码,成为你动手做自己第一个 AI 应用时的一个小小起点。码道漫漫,上下求索。


项目地址:https://atomgit.com/gcw_NAhdlPOi/liangxiaoerbianri
技术栈:HTML5 · CSS3 · JavaScript(原生)· DeepSeek-V4-Flash · SSE 流式输出
本文同步发布于"码道"系列开发手记。![请添加图片描述](https://i-blog.csdnimg.cn/direct/fa5d5d680f374ff7a010a3f7fa9e3e97.png

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐