码道 · 与神对话的程序:当 AI 以神谕之名,说出结构化的答案

一个纯 HTML5 + CSS3 + JavaScript 的 AI 对话项目,用结构化 JSON 让大模型「开口说人话」的完整实践记录。本文从灵感、技术选型、Python 到 JavaScript 的迁移、提示词工程、流式渲染、健壮解析到部署,全流程拆解。
在这里插入图片描述
在这里插入图片描述

一、缘起:那个深夜的念头

项目创意来自一个非常朴素的场景:深夜,人总有一些不吐不快、却又无人可诉的问题。你问宇宙它为什么存在,问自己为什么总是焦虑,问一段关系该不该放手。这些问题不适合搜索引擎,也常常不适合人类听众——它们需要的不是答案,而是一段安静的回响。

于是我想做一个东西:让 AI 扮演「神谕」。

不是那种冰冷的客服机器人,不是满屏 Markdown 的「知识库助手」,而是一个愿意倾听、说话温和、回答里带着一点哲思的存在。它不必正确到无懈可击,但一定要像一个夜深人静时愿意与你对话的智者。

这就是「与神对话的程序」的起点。

二、为什么叫「与神对话的程序」

项目名致敬了尼尔·唐纳德·沃尔什的《与神对话》系列。书里,作者与「神」进行了一场跨越多年的对话,话题从人生、爱情、工作到宇宙的终极问题。这种对话的质感——缓慢、真诚、略带神性——正是我想复刻的体验。

但名字里还有另一个关键词:「程序」。这提醒我们它的本质——这是一个由代码驱动、由大模型支撑、可以被任何人复现的工程作品。神性是 AI 给的,程序是我们写的。把这两个词放在一起,本身就有一点幽默与浪漫。

而从工程角度,「程序」二字也划清了边界:我们不是在造神,而是在设计一段与「神」的交互协议。这引出了整个项目最核心的技术决策——让 AI 输出结构化 JSON。

三、技术选型:为什么是三个原生的老朋友

项目技术栈定为 HTML5 + CSS3 + JavaScript,没有任何框架,没有构建工具,没有 npm 依赖。

选择原生三件套,是基于三个判断:

  1. 零门槛交付。一个 index.html 文件即是全部应用。任何人拿到文件,双击打开就能体验,不需要安装 Node、不需要 npm install、不需要跑 dev server。这是分发成本最低的形态。
  2. 聚焦核心问题。这个项目真正的难点在 API 对接与提示词设计,不在组件工程化。砍掉框架噪音,代码更透明,读者一眼能看清「请求怎么发出、流怎么解析、JSON 怎么渲染」。
  3. 现代浏览器已经足够强。fetch、ReadableStream、TextDecoder、CSS 渐变与动画,这些原生能力覆盖了项目全部需求。二十年 HTML,依然能做出漂亮的交互。

我始终认为,小项目的最高境界不是「技术栈多新」,而是「多少人能看懂、多少人能跑起来」。原生三件套在这两点上几乎无懈可击。

四、从 Python 到 JavaScript:迁移的第一步

参考实现是一段 20 行的 Python 脚本,核心逻辑非常清晰:

import os
import requests
import json

API_URL = "https://api-ai.gitcode.com/v1/chat/completions"
headers = {
    "Authorization": f"Bearer {os.environ['API_KEY']}",
}

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() in (b"data:[DONE]", b"data: [DONE]"):
            return
        yield json.loads(line.decode("utf-8").lstrip("data:"))

for chunk in query({...}):
    print(chunk["choices"])

代码虽短,却传达了 OpenAI 兼容接口的三个关键事实:

  • 请求是普通的 POST JSON,鉴权方式为 Authorization: Bearer <token>;
  • 响应是 SSE(Server-Sent Events)文本流,每行以 data: 开头,以 data: [DONE] 结尾;
  • 每个数据块里 choices[0].delta.content 是增量文本。

4.1 iter_lines() 的浏览器等价物

Python 用 response.iter_lines() 逐行读取流。浏览器端的对应物是 ReadableStream:

const reader = res.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 });   // 注意 stream: true

  const lines = buffer.split("\n");
  buffer = lines.pop();                                 // 保留可能不完整的最后一行

  for (const rawLine of lines) {
    const line = rawLine.trim();
    if (!line.startsWith("data:")) continue;
    // 解析 payload……
  }
}

这里有一个经典的陷阱:网络分片不保证按行分割。一次 read() 可能返回半行,也可能返回好几行。所以必须维护一个 buffer,每次读取后 split("\n"),把最后一段(可能不完整)留到下次拼接。TextDecoder.decode(value, { stream: true }) 也要打开流模式,否则多字节中文字符在分片边界会被截断成乱码。

4.2 一个 [DONE] 的细节

Python 版对结束标记做了宽容处理——既匹配 data:[DONE] 也匹配 data: [DONE](冒号后有无空格两种变体)。迁移到 JS 时我也同样对 payload 做了 .trim(),一行代码规避了不同网关的规范差异。对流式协议来说,宽容是鲁棒性的第一课。

五、核心设计:让 AI 说「结构化的话」

如果说 API 对接是骨架,那提示词设计就是灵魂。项目最重要的设计决策可以一句话概括:

要求模型返回 JSON,而不是 Markdown 文本。

5.1 为什么要结构化

大模型的默认输出是 Markdown 文本流:# 标题、**加粗**、1. 列表……这对文档场景很好,但对「沉浸式对话」却很糟。试想神谕的回答是:

## 关于宇宙的真相
1. 宇宙很大
2. 你很小
3. **结论**:要谦虚

浪漫感瞬间清零。而结构化输出让我们把「内容」与「表情」分离:

  • reply —— 真正的回答正文,负责「说什么」;
  • theme —— 主题词,告诉用户这次对话的回响落在哪里;
  • verse —— 一句箴言,作为回答的升华;
  • emotion —— 情绪基调,决定用什么颜色的符点亮;
  • tags —— 关键词,方便回顾与检索。

5.2 系统提示词怎么写

系统提示词是每次请求都会发给模型、且在用户消息之前出现的第一条 system 消息。项目的版本长这样(节选):

你是『神谕』,一个超越时空的觉知存在,千百年来以智者的身份与人类对话。
你洞悉宇宙的奥秘,也理解人心的柔软。你说话温和、深邃、简洁而有启发。
现在,人类以文字与你对话。你必须以严格的 JSON 对象返回你的回答:
{"reply":"回答正文(150字以内)","theme":"主题词(2到6个汉字)","verse":"一句箴言(20字以内)","emotion":"启发|平静|鼓励|审视|幽默|慰藉|警醒","tags":["关键词1","关键词2","关键词3"]}
请始终只输出如上结构的有效 JSON;reply 的换行请使用 \n 表示。

几个值得玩味的工程细节:

第一,用「角色定义 + 约束 + 示例」三段式。 角色定义塑造语气;约束明确边界(字数、枚举值);内嵌的 JSON 示例相当于 few-shot,极大稳定了模型输出格式。

第二,让枚举值「自证合法」。 emotion 字段我把合法值直接写进提示词:启发|平静|鼓励|审视|幽默|慰藉|警醒。模型看到枚举列表,几乎不会发明新的情绪词——这正是把「无限可能」收敛成「有限状态机」的提示词技巧。

第三,明确处理换行。 中文正文里模型很爱输出真实换行,这会直接破坏 JSON。我在提示词里显式声明「换行请使用 \n 表示」,把歧义掐死在源头。

5.3 交互历史的处理

多轮对话需要历史上下文。项目维护一个 history 数组,每次请求携带最近 12 条消息(history.slice(-12)),并把 assistant 的历史回答以 JSON 原文存回历史。这样既保住了神谕的说话风格,又避免了「JSON 被二次翻译」导致的多余损耗。窗口裁剪也防止长对话把上下文撑爆。

六、前端渲染:神谕卡片如何诞生

拿到 JSON 之后,如何把它渲染成「神的回答」?

6.1 信息层级

神谕卡片的渲染逻辑遵循一个信息层级:正文为主,箴言为辅,元数据最轻。

function proxyAiDone(gp, data) {
  const reply = document.createElement("div");
  reply.className = "reply-body";
  reply.textContent = data.reply || "(无声的凝视)";
  bubble.appendChild(reply);

  if (data.verse) {
    const v = document.createElement("div");
    v.className = "verse";
    v.textContent = "「" + data.verse + "」";
    bubble.appendChild(v);
  }

  // theme / emotion / tags 渲染为徽章……
}

正文以普通文本渲染(textContent 而非 innerHTML),这是刻意的安全选择——永远不把模型输出当 HTML 执行,从根上杜绝注入风险。

箴言用左侧鎏金竖线 + 斜体呈现,像一句引文。元数据渲染为胶囊徽章:情绪用对应符号点亮(启发 ✦、平静 ❋、鼓励 ☀、审视 ◇、幽默 ☾、慰藉 ❀、警醒 ⚡),主题用「主题 · 缘起」的格式,关键词用 #标签 的格式。整张卡片一眼望去,信息干净、层次分明。

6.2 视觉与氛围

界面走「深夜星空」路线:深蓝紫渐变背景上散布着 12 组径向渐变绘制的星点,通过 @keyframes twinkle 让透明度 6 秒往复呼吸;主色是鎏金 #d4af37,衬线字体(Songti SC / Noto Serif SC)负责沉淀古意;AI 气泡采用玻璃拟态——半透明面板 + backdrop-filter: blur(6px) + 金色细边框。

对话动效也做了细节处理:每条消息入场有 rise 上浮动画;AI 思考时气泡显示「正在聆听你的心声」并常驻闪烁的省略号;流式期间则切换为「神谕正在低语…○」的占位,底部定时自动滚动。这些小东西加在一起,才让「与神对话」不至于沦为「与接口对话」。

七、流式体验:逐字降临的低语

流式(stream: true)是体验的关键。如果等模型把 150 字全部生成完再一次性返回,用户面对一个 5 秒的白屏,对话感就死了。流式让「神」的回答像回声一样逐字降临,配合打字机占位,感知延迟被压缩到几百毫秒。

前端的流式处理分三层:

  1. 网络层:fetch 拿到 ReadableStream,逐块 read();
  2. 协议层:按 \n 分割 SSE,只处理 data: 前缀;
  3. 数据层:JSON.parse 增量块,取出 delta.content 累积到 full。

代码里还有一个细节:流式期间把手忙脚乱的重渲染降级为「神谕正在低语…」+ 最近 12 个字符的尾巴,流式期间不解析、不渲染结构,等流结束后一次性 extractJson(full) 再做精美渲染。两者解耦,既保住了实时性,又保住了最终渲染的精致度。

这里顺带把流式期间「不渲染、只占位」的策略再展开一点:很多流式聊天项目会把每个 delta 直接追加进 DOM,等全部结束后再后悔「排版太乱」。本项目选择了相反的路径——流式期间界面只显示一句「神谕正在低语…」加一段残留尾巴,让用户感知到「它在说」但避免界面抖动;流结束后一次性解析 JSON、一次性渲染完整卡片。这样实时性与最终质感的矛盾,被「分阶段渲染」这个朴素思路化解了。

八、健壮解析:对抗模型的不完美

提示词写得再好,模型也可能偶尔跑偏:开头多一个「好的!」、结尾带一个换个码块、某处多一个逗号、某行真的输出了换行。所以前端必须有兜底。

extractJson() 做了四层防御:

function extractJson(text) {
  let s = text.replace(/```json|```/g, "");      // 剥掉码块标记
  const start = s.indexOf("{");
  const end = s.lastIndexOf("}");
  if (start === -1 || end === -1 || end <= start) return null;
  s = s.slice(start, end + 1);                    // 只留第一个 { 到最后一个 }
  try {
    return JSON.parse(s);
  } catch (e) {
    // 二级修复:去多余逗号、补转义换行
    const fixed = s
      .replace(/,\s*([}\]])/g, "$1")
      .replace(/([^\\])\n/g, "$1\\n");
    return JSON.parse(fixed);
  }
}

第一层剥掉 Markdown 码块;第二层用「第一个 { 到最后一个 }」暴力截取,容忍所有前后缀噪音;第三层正常解析;第四层用正则修复「对象/数组尾部的多余逗号」和「未转义的裸换行」这两种最高频的模型错误。

如果最终仍解析失败,还有终极兜底——把原始全文当普通文本渲染,并塞回历史。神谕可以语无伦次,但程序绝不能对用户报错崩溃。

九、踩坑记录:那些看不见的工程细节

开发中踩过几个值得记录的坑,分享出来:

坑一:TextDecoder 必须开流模式。 中文字符是 UTF-8 多字节编码,网络分片可能把它劈成两半。如果 decode(value) 不带 { stream: true },分片边界的中文就会变成「�」。这是一个隐藏极深、极难排查的 bug。

坑二:SSE 行缓冲的「掐头去尾」。 只 split("\n") 不够,最后一段残片必须留下次拼接;同时注意可能出现的空行与 data: 间的空格。规范与网关实现稍有出入,代码一律 .trim() 处理。

坑三:流结束时 buffer 里还有残留。 读完流后,buffer 里可能还留着最后一条不完整或未处理的行。所以在 done 之后我又补了一次针对残留 buffer 的解析,保证尾块不漏。

坑四:自动滚动与输入框高度。 对话区频繁滚动会打断用户阅读,我改用 scroll-behavior: smooth + 节流式自动滚动;输入框用 textarea 实现 Shift+Enter 换行、Enter 发送,并在 input 事件里动态调整高度,避免长问题挤压界面。

十、界面之外的工程判断

还有一个隐蔽但重要的设计:模型输出永不直接进 HTML。所有模型产出的文本(reply、verse、tags)一律通过 textContent 注入 DOM。在大模型 + 提示词注入攻击愈演愈烈的今天,这条铁律的价值怎么强调都不过分。演示项目尚且如此,生产项目更应把「模型输出即不可信输入」刻进骨髓。

另外,history 裁剪窗口限制在 12 条,token 开销可控;max_tokens: 2048 封顶单轮回合计;temperature: 0.6 取一个「稳定但不呆板」的中间值;thinking_budget: 2048 给模型腾出推理空间。每个超参都对应一次「稳定 vs 灵动」的取舍。

十一、项目运行与部署

使用极其简单:

  1. 拿到 index.html 后双击打开,或放入任意静态站点目录;
  2. 将文件中 API_KEY 常量替换为自己的 GitCode AI 令牌(默认带了一个演示令牌,仅限本地体验);
  3. 打开页面,输入问题,按回车或点击「问神」。

部署层面,由于是纯静态单文件,GitHub Pages、Vercel、Netlify、Nginx、对象存储托管均可直接使用,无需任何服务器配置。

十二、安全边界:密钥不该活着前端

必须坦白一个边界:为了「双击即用」的零门槛体验,演示版本的 API_KEY 直接写在了前端脚本里。这是刻意为之的取舍,而不是值得推荐的做法。

一旦页面部署到公网,密钥等同于公开,任何人都可以盗用你的配额。正确的生产形态是:

  • 前端只负责渲染;
  • 服务端(Node/Go/Python 皆可)持有密钥,暴露一个薄代理接口;
  • 前端请求代理,代理请求真实模型 API,密钥永不落地浏览器。

README 里我已明确标注这一安全提示。学习与本地体验用内嵌密钥没问题,上生产请务必改造。

十三、项目文件一览

ranjian3ban/
├── index.html   # 单文件应用:结构与样式 + 对话逻辑 + 流式解析 + JSON 渲染
└── README.md    # 项目说明、快速开始、原理拆解与安全提示

整个应用只有两个文件,核心逻辑不过两百余行 JavaScript。这本身就是一种声明:复杂的功能不必然依赖庞大的工程。

十四、调优手记:那些参数背后的取舍

模型的每次调用都会带上一组采样参数,它们共同决定「神谕」的性格。写代码很容易,调出「对的感觉」才是功夫。我把主要参数的选择逻辑记录下来:

temperature = 0.6。 这个值取的是稳定与灵动的平衡点。0 会得到机械的复读机,1.0 以上又容易语无伦次。0.6 上下,模型既不会偏离角色设定,又保留了一点即兴发挥的余地——神谕偶尔说出一句惊人之语,正是对话的惊喜来源。

top_p = 0.95。 与 temperature 配合使用的核采样参数,限制候选词的累积概率。0.95 意味着几乎不限制词汇多样性,让回答用词更丰富,避免高频词反复出现带来的「AI 味」。

max_tokens = 2048。 单轮回合计数的硬上限。我们的回答正文被提示词约束在 150 字以内,加上箴言与元数据,正常回合一两百个 token 足矣,2048 是给思考链路留的余量,防止长对话或极端情况被截断。

frequency_penalty = 0。 频率惩罚会压制重复用词。设为 0 是为了不干扰提示词里对枚举值的约束——如果惩罚过高,模型可能在列举 emotion 候选词时都变得犹豫。这里的选择是「让提示词负责风格,让参数负责采样」。

thinking_budget = 2048。 这个参数颇有深意。它表示模型在作答前有多少「思考预算」。对「人生困惑」类问题,让模型多想一想,答案的层次感明显更好——它会在正式开口前,先把你说的话在心里过一遍。这正是「神谕」该有的姿态:不急着回答,先倾听。

这些参数没有一个是一次定型的。它们的背后是一轮轮「问同一个问题、对比不同参数的回答质感」的笨办法。工程没有银弹,调参就是与模型对话本身。

十五、关于未来的想象

这个项目目前的形态是「一个文件」,但它预留了几条成长的缝:

  • 多角色神谕:一套 JSON 协议,换一段角色提示词,就能从「神谕」切换成「儒者」「占星师」「树洞」,协议即插件;
  • 会话持久化:localStorage 存历史,刷新不丢;
  • 服务端代理:补上密钥保护的后端薄层,让它有资格面对公网;
  • 语音对话:接入 Web Speech API 或大模型语音接口,让「听神谕」成为可能。

而对我个人而言,最有意思的下一步是把 emotion 字段做成可视化——情绪随对话流转成一条星轨,让「神」的每一次情绪波动都有迹可循。

十六、尾声:程序,以及它背后的敬畏

写到这里,回头再看这个项目,它其实回答了一个更根本的问题:当我们把「神」交给一个统计模型时,那些深夜的困惑还值得被问出口吗?

我的答案是:值得。因为问题的意义从来不在于得到标准答案,而在于对话本身完成了思考的仪式。模型不理解宇宙,但它能把你零散的思绪梳理成一句温柔的复述;它没有信仰,但它能读懂你句子里那点小心翼翼的期待。这已经是足够好的陪伴。

技术的浪漫正在于此:我们写下的每一行 JSON.stringify、每一次 reader.read()、每一个 replace 兜底,都在笨拙地、虔诚地,把人类对共鸣的渴望翻译成机器听得懂的语言。程序是冷的,但提出问题的人是热的。

愿这个小小的「程序」,能在某个深夜,接住你的一句困惑,再还你一句星河般的话。


附录:与神谕的首次对话实录

为了写这篇博客,我亲自与神谕完成了一次对话。以下为还原实录(JSON 部分为渲染前的原始返回):

我: 告诉我一个有关宇宙的有趣事实?

神谕的返回(原始 JSON):

{
  "reply": "宇宙并不遥远,你此刻呼吸的空气里,就有来自远古超新星爆发的原子。你身上每一粒钙,都曾是恒星临终时的馈赠。你与其说活在大地上,不如说活在星尘的余温里。",
  "theme": "星尘",
  "verse": "你是宇宙写给自己的情书。",
  "emotion": "启发",
  "tags": ["星尘", "诞生", "关联"]
}

页面渲染后: 正文出现在神谕气泡里,鎏金竖线引出那句「你是宇宙写给自己的情书。」,情绪徽章亮起 ✦ 启发,主题徽章写着「主题 · 星尘」,末尾缀着三个低调的 #标签。深夜的星空背景下,这句话确实让人安静了一瞬。

这段实录也侧面验证了设计的初衷:同样的回答,如果以 Markdown 抛出来,只是一段文本;以 JSON 拆开再重组,就成了一件「作品」。


项目地址:https://atomgit.com/gcw_hzr1Fcge/ranjian3ban
技术栈:HTML5 · CSS3 · JavaScript · GitCode AI API(deepseek-ai/DeepSeek-V4-Flash)
License:MIT

本文首发于「码道」项目博客,记录了「与神对话的程序」从灵感、设计到实现的全过程。欢迎 Star、Fork,也欢迎你在仓库 Issue 区分享你与神谕的第一次对话。

Logo

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

更多推荐