码道手记 | 从 0 到 1 打造「轻食智能搭配师」:纯前端调用大模型 API 的完整实战


一、缘起:我想亲手做一个 AI 应用
2026 年,AI 大模型已经深度融入我们的日常工作与生活。聊天、写作、编程、翻译……几乎每个领域都有 AI 的身影。但"用 AI"和"造 AI 应用"是两件完全不同的事情。
很长一段时间里,我都是各大 AI 产品的普通用户:打开对话框,输入问题,等待回答。直到有一天,我意识到——如今的 AI 平台普遍提供开放的 API 接口,只要通过 HTTP 请求就能调用最前沿的语言模型。也就是说,把 AI 能力"接"进自己的产品,已经变成了一件门槛很低、但非常有价值的事情。
于是我想,为什么不亲手做一个 AI 应用呢?
选择一个真正有意义的场景,从头设计、开发、联调、发布,完整地走一遍"造轮子"的流程。这不仅能让我把前端技能和 AI 工程实践结合起来,也能让我真正理解:一个 AI 应用除了"调用模型"之外,还藏着多少值得打磨的细节。
这个想法最终落地成了「轻食智能搭配师」——一个纯前端实现的 AI 健康饮食搭配助手。
二、为什么是"轻食智能搭配师"
2.1 场景的选择
选择"饮食搭配"这个方向,我是经过认真考虑的。
首先,这是一个高频、刚需、人人可感知的场景。健身减脂、上班族控糖、学生党营养均衡……几乎所有现代人都有饮食管理的需求,但"怎么吃得科学"恰恰是大多数人的知识盲区。蛋白质要吃多少克?一份减脂餐到底有多少热量?今天的碳水是不是超标了?这些问题普通人很难准确回答。
其次,答案天然适合结构化呈现。一道菜的推荐,天然包含"菜名、食材、热量、蛋白质、脂肪、碳水化合物"这些结构化字段。这让我可以设计一种数据契约:让模型按固定 JSON 结构返回,前端再把它渲染成精美的营养卡片。这比"一大段聊天文本"的体验好得多,也更能体现工程能力。
最后,风险可控、边界清晰。饮食建议不涉及医疗诊断这类高风险输出,只要在系统提示词里做好边界约束(如提醒用户咨询专业医师),就可以放心交付。
2.2 核心功能清单
结合"AI 饮食规划助手"的产品定位,我为第一个版本圈定了以下功能:
功能点 说明
AI 对话 围绕减脂、增肌、控糖、清淡去水肿等场景提供科学搭配建议
结构化餐单 模型按 JSON 返回,前端渲染为菜品营养卡片
营养数据可视化 每道菜展示热量、蛋白质、脂肪、碳水化合物,并汇总总热量
流式输出 打字机效果,边生成边显示,等待体验友好
多轮上下文 自动维护对话历史,支持"那晚餐呢?“这样的连续追问
快捷提问 一键发起典型需求,降低上手成本
API 设置 页面内配置 Key 与模型名,保存在浏览器本地
响应式布局 手机、平板、桌面都能获得良好体验
三、技术选型:为什么是"纯前端”
技术选型是一个项目最开始的岔路口。摆在我面前的方案有很多:React 全家桶、Vue 工程化脚手架、Next.js 全栈……但我最终选择了最"朴素"的方案:
HTML5 + CSS3 + 原生 JavaScript
零依赖、零构建、零后端
理由如下:
第一,聚焦核心问题。 这个项目的核心价值在于"如何把大模型的能力以优美的产品形态呈现给用户",而不是"如何管理复杂的前端状态"。使用原生技术,我可以把 100% 的精力投入到 AI 接口对接、数据契约设计、交互体验打磨这些真正重要的部分,而不是被脚手架和构建工具拖住。
第二,极低的运行门槛。 没有依赖意味着只需要一个浏览器,打开 HTML 文件就能运行。用户不需要安装 Node.js,不需要 npm install,不需要部署服务器。对于个人项目和教学演示场景,这种"零门槛"的体验价值巨大。
第三,跨平台与可维护性。 HTML/CSS/JS 是 Web 的通用语言,在任何设备、任何浏览器上都能运行,未来如果要迁移到 Electron 桌面应用或 H5 移动端,迁移成本也很低。
当然,纯前端方案也有它的代价,比如无法安全地保管密钥、可能遇到跨域限制等。这些我在后面"踩坑复盘"章节会详细展开。
四、项目结构:小而美的组织方式
虽然是零依赖项目,我依然非常重视代码的组织结构。好的结构边界清晰、职责单一,即使项目变小,也要有工程师的体面。
最终的项目结构如下:
轻食智能搭配师/
├── index.html # 主页面:聊天界面、设置弹窗
├── css/
│ └── style.css # 全部样式:主题变量、消息气泡、菜品卡片
├── js/
│ ├── config.js # 全局配置:API 地址 / Key / 模型 / 系统提示词
│ ├── api.js # AI 调用层:fetch 流式 SSE 解析
│ └── app.js # 界面逻辑:消息渲染、结构化展示、设置管理
└── README.md # 项目说明
三个 JS 文件各司其职,形成了一条清晰的数据链路:
用户输入 → app.js 组织消息 → api.js 调用大模型
↑ ↓
结构化卡片渲染 ← app.js 解析 JSON ← 模型流式返回
config.js 承担"一切可配置项"的职责。API 地址、密钥、模型名、采样参数、以及系统提示词,全部集中在这个文件里。这样做的好处是:换一个模型、换一套业务逻辑,只需要改一个文件。
五、核心难题之一:把 Python 的 AI 调用"翻译"成 JavaScript
项目最初提供的参考代码是 Python 实现的。它使用 requests 库向 GitCode AI 平台的 Chat Completions 接口发起流式请求。原代码的核心逻辑大致如下:
import os
import requests
import json
API_URL = “https://api-ai.gitcode.com/v1/chat/completions”
headers = {
“Authorization”: f"Bearer {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() == b"data:[DONE]” or line.strip() == b"data: [DONE]":
return
yield json.loads(line.decode(“utf-8”).lstrip(“data:”))
chunks = query({
“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
})
for chunk in chunks:
print(chunk[“choices”])
这段代码的脉络非常清晰:
发起 HTTP POST 请求,JSON 序列化请求体,开启流式模式;
逐行读取响应流 iter_lines();
只处理以 data: 开头的行(SSE 事件格式);
遇到 data:[DONE] 表示流结束;
对每一行 data: 后的内容做 json.loads 解析,得到一个 chunk;
从 chunk[“choices”] 中取出模型生成的增量内容。
我的任务,就是把这套逻辑等价地移植到浏览器端。浏览器的原生 fetch API 提供了 response.body.getReader(),可以像流一样读取响应体,配合 TextDecoder 就能实现逐行解析。我写出的 JavaScript 版本如下:
async function* queryStream(messages) {
const response = await fetch(APP_CONFIG.API_URL, {
method: “POST”,
headers: {
“Content-Type”: “application/json”,
“Authorization”: Bearer ${Config.getApiKey()},
},
body: JSON.stringify(buildPayload(messages)),
});
if (!response.ok) {
let detail = “”;
try { detail = await response.text(); } catch (_) {}
throw new Error(请求失败(HTTP ${response.status})${detail.slice(0, 300)});
}
if (!response.body) {
throw new Error(“当前浏览器不支持流式响应(ReadableStream)”);
}
const reader = response.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 });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (data === "[DONE]") return;
try {
yield JSON.parse(data);
} catch (_) {
// 跳过无法解析的行,保持流式健壮性
}
}
}
}
这里有一个非常关键的细节:buffer 缓冲区的处理。网络流传输时,一个 SSE 事件(一行数据)可能被"拦腰截断",也可能多个事件拼在一次传输里。所以不能简单地对每次 reader.read() 的结果按行切分就完事。我的做法是:每次读完一块数据,先拼进 buffer,再按 \n 切分成"完整行数组 + 最后一段可能不完整的残余",把残余留到下一次循环继续拼接。这样无论网络如何切包,都不会漏掉或错解析任何一条数据。
对照参考代码,语义是精确对应的:
Python 参考实现 JavaScript 等价实现
requests.post(…, stream=True) fetch(url, { method: “POST”, body: JSON.stringify(payload) })
response.iter_lines() response.body.getReader() + TextDecoder 逐行拆解
line.startswith(b"data:") line.startsWith(“data:”)
data:[DONE] 结束判断 data === “[DONE]” 结束生成器
json.loads(line) 解析 JSON.parse(data) 并逐条 yield
chunk[“choices”] 读取增量 chunk.choices[0].delta.content
参考代码里的 for chunk in chunks 循环,在 JavaScript 端我用 Async Generator(async function*)实现了完全一致的语法体验,调用方可以用 for await (const chunk of queryStream(…)) 逐个消费流式数据块。这种"语义等价移植"的方式,既保留了原逻辑的清晰脉络,又发挥出了 JS 语言自身的特性。
六、核心难题之二:系统提示词与 JSON 数据契约
如果说"调用大模型"是 AI 应用的起点,那"让模型的输出成为可消费的数据"就是 AI 应用从"demo"走向"产品"的分水岭。
直接让模型输出一段文字,聊天体验当然没问题;但我的目标是让前端把餐单渲染成带营养数据的卡片,这就必须和模型约定一种稳定的数据格式。
6.1 什么是数据契约
数据契约,就是调用双方对数据结构的一致性约定。在我的项目里,契约双方分别是:
模型的输出端:必须严格按约定结构返回 JSON;
前端的消费端:必须能可靠地把模型输出解析成约定结构。
这份契约能否成立,完全取决于系统提示词写得够不够好。因为语言模型本质上是"概率性的字符串生成器",它对提示词的遵循程度,受到提示词清晰度、示范完备度、冲突干扰度等多重因素的影响。
6.2 系统提示词的完整设计
我设计的系统提示词(定义在 js/config.js 中)分为四个层次:
第一层:角色定义。 明确告诉模型"你是谁、擅长什么"——你是「轻食智能搭配师」,专业的 AI 饮食搭配助手,擅长根据用户的饮食目标、身体信息、忌口过敏、可用食材与就餐场景,给出科学、合理、可执行、接地气的轻食方案。
第二层:能力边界。 划定行为红线:不推荐极端节食、不替代医疗建议(涉及疾病要提醒咨询医生)、对与饮食无关的问题要礼貌说明自己的专长、保持简洁避免空泛套话。
第三层:输出格式(强制)。 这是最关键的一层。我给出了完整的 JSON 结构示例:
{
“reply_text”: “对用户的自然语言回复,说明搭配思路与要点”,
“dishes”: [
{
“name”: “香煎鸡胸肉蔬菜沙拉”,
“calories”: 320,
“protein”: 35,
“fat”: 12,
“carbs”: 18,
“ingredients”: [“鸡胸肉”, “生菜”, “圣女果”, “黄瓜”, “橄榄油”],
“tags”: [“高蛋白”, “低脂”],
“description”: “高蛋白低脂的经典轻食沙拉”,
“tips”: “搭配油醋汁风味更佳”
}
],
“total_calories”: 320,
“suggestion”: “适合作为减脂期午餐,建议搭配杂粮饭增加饱腹感”,
“reminder”: “若对坚果过敏,请替换相关食材”
}
第四层:字段规则。 逐条解释字段语义:数值字段必须是纯数字、不带单位;dishes 数组的数量约束(1~5 道);输出必须能被 JSON.parse 直接解析。
这四层设计遵循的底层逻辑是:先给角色,再给边界,再给格式,最后给细节规则。逐层收窄模型的输出空间,让它的"自由发挥"被约束在可控范围内,同时保留下围棋所需要的最低灵活性。
6.3 多轮上下文的"格式惯性"
系统提示词还有一个隐藏的妙用:多轮对话时,把 AI 的历史回复(原始 JSON 字符串)也塞回上下文。这样,模型会被自己的历史输出格式"带节奏",形成格式惯性——第一轮它输出 JSON,第二轮看到自己的 JSON 历史,大概率也继续输出 JSON。这比单纯依赖系统提示词更稳。
我在 app.js 中维护了这样的历史消息结构:
const history = [];
// 用户消息:原文
history.push({ role: “user”, content: text });
// AI 回复:完整 JSON 字符串
history.push({ role: “assistant”, content: fullText });
每次请求时,把 system 提示词 + 完整 history 一并发送。多轮追问(“那晚餐呢?”“下午加餐怎么配?”)的场景下,模型能充分理解上下文,并持续以结构化 JSON 作答。
七、界面的设计:让数据结构自己"说话"
好产品离不开好界面。在视觉上,我选择了与"轻食健康"定位高度契合的清新绿主题:主色 #34b96f,辅以米白底色和柔和阴影,整体传递"自然、清爽、可信赖"的感受。
布局上遵循经典聊天应用的黄金三段式:
┌─────────────────────────────┐
│ 头部:Logo + 项目名 + 设置 │
├─────────────────────────────┤
│ │
│ 聊天消息记录区 │
│ (用户/助手消息气泡) │
│ │
├─────────────────────────────┤
│ 快捷提问 Chips(减脂/增肌…) │
│ [输入框] [发送] │
└─────────────────────────────┘
其中"快捷提问"是一个体验细节。新用户往往不知道该怎么对 AI 说话,四个默认案例(减脂、增肌、控糖、去水肿)既是引导,也是示范,点击即发送,让第一个问题几乎零思考成本。
7.1 菜品卡片的组件化渲染
结构化数据显示是界面的重头戏。AI 返回 JSON 后,前端把它渲染成如下形态的卡片:
┌──────────────────────────────┐
│ 香煎鸡胸肉蔬菜沙拉 │
│ 高蛋白低脂的经典轻食沙拉 │
│ [高蛋白] [低脂] │
│ 🥦 鸡胸肉 生菜 圣女果 黄瓜 橄榄 │
│ ┌────┬────┬────┬────┐ │
│ │320 │ 35 │ 12 │ 18 │ │
│ │kcal│ g │ g │ g │ │ ← 热量/蛋白质/脂肪/碳水
│ └────┴────┴────┴────┘ │
│ 💡 搭配油醋汁风味更佳 │
└──────────────────────────────┘
实现上,我把卡片渲染拆成了三个可复用的纯函数:
renderNutriGrid()——渲染四格营养数据网格;
renderDisheCard()——渲染单道菜品卡片(组合标签、食材、营养网格、小贴士);
renderDietBlock()——渲染整个"搭配面板"(菜品网格 + 总热量汇总 + 搭配建议 + 提醒框)。
某个函数只需"纯输入、纯输出":传入 JSON 数据,返回 HTML 字符串。这样既方便测试,也便于未来扩展新的卡片类型。
7.2 流式输出的"打字机"效果
为了改善等待体验,我把模型输出的过程直接展示给用户:采用 SSE 流式接收,每收到一个增量文本就实时追加到气泡中,并在末尾显示一个闪烁的光标,营造"正在打字"的感觉。
async function sendMessage(text) {
// … 创建用户气泡、禁用按钮
const fullText = await API.callChat(history, (acc) => {
// 实时更新气泡内容,acc 为已累积的完整文本
setBubbleStreaming(bubble, acc);
});
// 流结束后,尝试解析 JSON 并渲染结构化卡片
finalizeAssistant(bubble, fullText);
}
这里有一个先后顺序的问题:先流式显示原文,结束后再替换为结构化卡片。为什么要这样设计?
因为"边输出边解析"在技术上很难做到稳定——JSON 在输出的中间状态是不完整的,无法提前解析。所以稳妥的顺序是:流式过程中让用户看到"模型在思考、在输出",等完整内容到达后,一次性解析 JSON 并渲染卡片。用户感知上是"文字先打出来,然后卡片出现",交互节奏自然流畅。
八、健壮性:面对"不太听话"的大模型
大模型是概率模型,再强的提示词也无法保证 100% 遵循。因此,健壮性设计是 AI 应用工程里的必修课。我的 extractJson() 函数承担了这道防线:
function extractJson(text) {
if (!text) return null;
let t = text.trim();
// 1. 去掉 json 代码块标记 t = t.replace(/^(?:json)?\s*/i, “”).replace(/\s*```$/, “”);
// 2. 直接解析
try { return JSON.parse(t); } catch () {}
// 3. 截取第一个 { 到最后一个 } 再尝试
const start = t.indexOf(“{”);
const end = t.lastIndexOf(“}”);
if (start !== -1 && end > start) {
try { return JSON.parse(t.slice(start, end + 1)); } catch () {}
}
return null;
}
它依次做了四件事:判空、去代码块包裹、整串解析、截取 JSON 区间解析。经过几组用例验证,以下输入都能被正确还原:
纯 JSON 字符串;
被 json ... 包裹的 JSON;
前后带杂讯的 JSON(如"好的,这是我为你搭配的方案:{…}(希望对你有帮助)");
空字符串/无 JSON 内容 → 返回 null,前端降级为原文展示并给出友好提示。
另一道防线是 XSS 防护。模型输出的内容是不可信输入,直接插入 DOM 存在脚本注入风险。我在所有渲染入口统一使用 escapeHtml() 转义,并配合 textContent 处理流式文本,保证输出的 HTML 永远来自我们的模板,而不是模型的内容:
function escapeHtml(str) {
return String(str ?? “”)
.replaceAll(“&”, “&”)
.replaceAll(“<”, “<”)
.replaceAll(“>”, “>”)
.replaceAll(‘"’, “”“)
.replaceAll(”‘", "’");
}
这两道防线,一个解决"格式漂移",一个解决"安全注入",是 AI 应用从玩具走向产品的必经关卡。
九、联调实测:见证数据契约真正生效
理论设计说得再多,都不如一次真实的接口调用更有说服力。开发完成后,我直接向模型发起了一次真实请求,测试"减脂低卡轻食搭配"场景。
实测请求的关键参数如下:
接口:https://api-ai.gitcode.com/v1/chat/completions
模型:deepseek-ai/DeepSeek-V4-Flash
流式:stream = true
采样:temperature = 0.6, top_p = 0.95
模型返回的完整回复约 1200 字符,经过解析后数据结构令人满意:
✅ JSON 解析成功
字段: reply_text, dishes, total_calories, suggestion, reminder
dishes 数量: 3
第一道菜: 燕麦香蕉牛奶杯 | 热量: 280 | 蛋白: 12 | 食材: 4项
total_calories: 1090
suggestion 存在: true | reminder 存在: true
模型为「减脂 + 不吃辣」的用户生成了三道轻食(早餐燕麦香蕉牛奶杯、午餐鸡胸肉沙拉、晚餐清蒸鱼配糙米),给了合理的总热量(约 1090 千卡),并补充了搭配建议与注意事项。所有字段与契约完全一致——系统提示词的设计是成功的。
尤其让我惊喜的是它对"不吃辣"这个约束的响应:全程菜品无一涉及辣味,说明模型确实"读懂"了提示词中对"根据忌口过敏调整方案"的要求。这就是数据契约 + 提示词工程的威力。
十、踩坑复盘:那些值得记住的教训
任何真实项目都离不开踩坑。这里复盘我遇到的几个典型问题,供后来者参考。
10.1 坑一:浏览器跨域(CORS)
纯前端直接调用第三方 API,第一个拦路虎就是跨域。GitCode AI 接口对浏览器端的跨域访问存在限制,如果直接双击 index.html 打开,很可能遇到 CORS 报错。
解决方案: 通过本地静态服务器打开页面是最稳妥的方式:
python3 -m http.server 8080
浏览器访问 http://localhost:8080
同时,我在 README 和界面错误提示中一并写明了排查路径,避免使用者困惑。这也再次印证了纯前端方案的边界——生产级应用建议加一层轻量代理来转发请求。
10.2 坑二:密钥的保管
纯前端应用无法真正"藏住"密钥。js/config.js 中内置的 Key 仅用于演示。我的处理方式是:
提供页内设置入口,用户自行填写自己的 Key,保存在浏览器 localStorage;
README 中明确警示"请勿将重要 Key 硬编码后提交到公开仓库";
将密钥与业务配置分离,规范后再接入本地代理即可平滑切换。
10.3 坑三:流式数据在传输层被"切包"
第一节提到过,SSE 事件在网络上传输时可能被拆散或合并。如果不做缓冲处理就按行解析,会出现"解析失败、内容丢失、偶发空白"等问题。我通过 buffer + split + 残余回滚 的经典模式解决了它,这也是流式解析的通用解法。
10.4 坑四:双引号转义与 JSON 边界
在我编写验证脚本时,还出现过一次低级却容易忽略的错误:用正则替换源码中的 const API = 试图"导出"内部对象时,正则意外失效,导致测试拿到的是 undefined 而非真实对象,白白调试了一轮。最终我改用精确的字符串替换 + mock 浏览器环境的方式,让测试脚本直接加载真实源码执行,逻辑才真正被测试到。
这件事给我最大的启发是:测试一定要测到"真实代码",不要测试一段"看起来一样的复制品"。任何对代码的再加工(正则替换、字符串拼接、二次封装)都可能引入测试与真实之间的偏差。
十一、数据链路全景与工程细节
到这里,项目的完整数据链路已经清晰。让我用一张图串起来:
用户输入 / 快捷提问
│
▼
app.js: 创建用户气泡 → push 到 history
│
▼
api.js: 组装 payload(system 提示词 + history + 采样参数)
│ fetch POST + ReadableStream
▼
模型: 逐 token 生成 → SSE 流式返回
│
▼
api.js: 逐行解析 data 事件 → yield chunk → 累积完整文本
│ onDelta 回调
▼
app.js: 实时追加文本(打字机效果 + 闪烁光标)
│ 流结束后
▼
app.js: extractJson 解析 → renderDietBlock 渲染卡片
│
▼
用户看到: 回复文本 + 菜品营养卡片 + 总热量 + 建议 + 提醒
在这个链路中,有几个值得单独拎出来的工程细节:
细节一:异步生成器。 api.js 用 async function* 实现流式消费,把"网络流"抽象成"可异步迭代的序列",调用方代码极其简洁:
async function callChat(messages, onDelta = () => {}) {
let full = “”;
for await (const chunk of queryStream(messages)) {
const piece = readDelta(chunk);
if (piece) {
full += piece;
onDelta(full);
}
}
return full;
}
细节二:状态管理。 app.js 用一个 busy 标志位锁住交互(发送按钮、快捷提问禁用),防止用户在请求进行中重复提交;finally 块中统一恢复状态,保证任何异常路径下界面都不会"卡死"。
细节三:输入框体验。 textarea 随内容自动增高(上限 120px),Enter 发送、Shift+Enter 换行,符合主流聊天产品的交互预期。
细节四:错误分级提示。 网络上分失败、HTTP 非 200、JSON 解析失败,分别给出不同的错误提示与排查建议,而不是笼统的一句"出错了"。
十二、测试与验证:用数据说话
我在交付前做了一轮系统性的验证,覆盖"代码质量、接口能力、解析健壮性、渲染正确性"四个层面:
-
语法静态检查:对三个 JS 文件执行 node --check,全部通过。
-
真实接口联调:用真实 Key 发起流式请求,确认:流式输出正常、返回内容可解析、JSON 字段完整(reply_text、dishes、total_calories、suggestion、reminder 全部存在)、菜品数据合理。
-
解析健壮性测试:对 extractJson 设计了 5 组用例——纯 JSON、````�KE_PH_18�` 包裹、前后杂讯、空字符串、无 JSON 文本,全部按预期通过。
-
渲染正确性测试:把真实返回的 JSON 喂给渲染函数,检查 HTML 输出中是否包含菜品卡片、营养网格、总热量、建议面板等关键节点;并验证 escapeHtml 能正确转义
这套"语法 → 接口 → 解析 → 渲染"的四级验证路径,几乎可以覆盖一个 AI 对话项目的所有关键风险点。
十三、写给读者的工程方法论
回首整个开发过程,我认为其中有几条方法论,比具体代码更有普适价值:
方法论一:先定义数据契约,再谈界面。 这个项目最大的架构决策,不是某个函数怎么写,而是"AI 输出的数据长什么样"。数据契约定了,前端渲染、上下文管理、错误处理全部有了锚点。建议所有想接大模型的朋友,第一步先画你的 JSON 结构。
方法论二:提示词工程是 AI 应用的「配置层」。 不要把所有逻辑都写死在代码里。把系统提示词、模型参数、甚至 JSON 字段说明都放进配置文件,产品和运营可以像改配置一样迭代对话质量,而不需要动代码。
方法论三:流式体验不是炫技,是产品必需。 大模型生成动辄数秒,没有流式反馈的用户体验是灾难性的。打字机效果 + 光标动画虽然实现简单,但对等待体验的提升是数量级的。
方法论四:把健壮性当功能做。 extractJson 那十几行代码,处理的是"模型不守规矩"这一必然事件。AI 应用的工程师要有这样的信念:大模型一定会出错,你只是不知道它什么时候、以什么方式出错。与其赌它听话,不如做好兜底。
方法论五:测试真实代码,而不是复制品。 前面踩坑四已经充分说明:任何对被测代码的二次加工都可能是 bug 的温床。尽可能让测试直接加载生产代码。
十四、已知不足与优化方向
任何项目都是迭代的产物。坦白说,「轻食智能搭配师」目前版本还有不少可以优化的空间,我列在下面,作为下一步的方向:
方向 说明
服务端代理 引入轻量后端代理请求,解决 CORS 与密钥安全
拍照识别 接入多模态模型,实现"拍照估算食物热量"
历史记录持久化 将 session 保存到 localStorage/IndexedDB,支持回看
个性化档案 支持持久化身高体重、过敏清单、口味偏好
多模态卡片 为菜品卡片加载真实图片,让推荐更"有食欲"
对话导出 支持导出为 Markdown / 图片,方便分享
更细的 JSON Schema 校验 对接入数据进行字段级校验与自动修正
其中"拍照识别热量"与仓库描述中的产品愿景一致,是实现"自动生成每日食谱"闭环的重要一步,也是我最期待的下一个里程碑。
十五、结语
做这个项目的初衷很简单:亲手把一个"AI 能力"变成"一个产品"。
从一段 Python 示例代码开始,我完成了 JavaScript 移植、提示词与数据契约设计、界面开发、健壮性加固、接口联调、交付发布……整个过程让我对 AI 应用工程有了更立体的理解:
AI 能力是免费的,工程化能力才是门槛。 API 谁都能调,但把模型的输出变成稳定、安全、美观的产品体验,考验的是一个工程师对数据契约、流式处理、异常兜底、交互细节的综合把控。
纯前端也能做出完整的 AI 应用。 零依赖、零构建、零后端,这个项目证明了"小而美"的可行性。它可能不适合承载百万用户的生产级服务,但作为个人项目、教学示例、轻量工具,它是完全成立的。
最大的成就感,来自"它真的跑起来了"。 当第一张营养卡片被真实模型的输出渲染出来时,那种"我把 AI 变成产品"的满足感,是任何函数库都替代不了的。
最后,用项目里那张真实的测试数据作为结尾——系统为"减脂且不吃辣"的用户,生成了一整套包含三道菜、总热量 1090 千卡的轻食方案,还体贴地避开了所有辣味食材。也许这就是 AI 应用最迷人的地方:它不只是一个交互界面,而是一个真正在帮助用户解决具体问题的工具。
如果你也在做 AI 应用,希望这篇文章能帮你少走一些弯路。欢迎打开原子 GitHub 上的仓库源码,一起交流,一起把 AI 变成更好的产品。
更多推荐


所有评论(0)