码道 · 用纯前端打造 AI 旅游路线规划助手

码道生成:

一、写在前面
如果你是一个经常出行的旅行爱好者,大概都经历过这样的时刻:打开搜索引擎,在"XX 城市旅游攻略"的页面之间来回跳转,把不同博主推荐的景点、美食、住宿信息手抄到一个备忘录里,然后自己一点一点拼凑出所谓的"行程单"。这个过程既繁琐又低效,等到真正出发的时候,往往发现行程里的时间安排根本不合理,景点之间隔了半个城市,一天要赶三四个地方,累得连拍照的力气都没有。
如果有一个助手,只要告诉它"我想去北京玩三天,预算两千块",它就能在几十秒内为你生成一份完整的行程方案——每天去哪里、玩什么、吃什么、住哪里、交通怎么衔接、预算怎么分配,全都安排得明明白白,那该多好?
本文要分享的,正是这样一个项目的完整实现过程。它叫"旅游路线规划 AI",是一个完全运行在浏览器端的 AI 对话应用。它不需要任何后端服务器,不需要数据库,不需要部署环境,只需要一个静态页面,加上一个可以用浏览器直接访问的 AI 大模型接口,就能帮你生成一份专业的旅行路线。
这个项目最特别的地方在于三个"纯":纯前端的架构设计、纯 JavaScript 的流式请求实现、纯 JSON 驱动的结构化渲染。下面我会从零开始,把项目的设计思路、代码实现、踩坑经历和优化过程完整地讲给你听。
二、项目的缘起与目标
2.1 一个朴素的愿望
做这个项目的想法,源于我和朋友的一次对话。我的一个朋友准备年底带父母去三亚度假,他问我:“你能不能帮我做个行程表?我看了好多攻略,越看越乱。”
我当时的第一个反应是:这种任务,其实完全可以让 AI 来做。现在的 AI 大模型在信息组织、行程安排这类任务上已经做得相当不错,尤其是经过精心设计的提示词引导之后,它能给出非常成熟、实用的旅行方案。
但我又想到一个问题:现在市面上的 AI 对话产品,虽然能给出一大段文字回答,但当你真的想把它保存下来、按照它去执行的时候,体验并不好。一大段纯文字里,景点、时间、费用混在一起,阅读成本很高。如果 AI 能返回结构化的数据,前端再把它渲染成漂亮的卡片式行程,那体验就完全不一样了。
2.2 明确项目的目标
带着这个想法,我给自己定下了几个清晰的开发目标:
第一,对话即规划。用户像聊天一样说出旅行需求,AI 像旅行规划师一样给出完整方案,整个过程自然流畅。
第二,结构化输出。AI 的回复不应该是长篇大论的纯文字,而应该是结构清晰的 JSON 数据:目的地、天数、预算、每日行程、景点清单、美食推荐、交通建议、住宿区域、预算分配明细,全部井然有序。
第三,纯前端实现。整个项目只使用 HTML5、CSS3 和 JavaScript,不引入任何后端框架和数据库。这意味着项目可以部署在任意静态托管平台上,打开即用。
第四,极致的渲染体验。拿到 JSON 之后,前端要将它渲染成让人一眼就能看懂的美观界面:每天的行程是一张独立的卡片,带主题标签;景点是带简介和游玩时长的信息条;预算是一个清晰的分配表格。
第五,完善的工程配套。项目需要配齐完整的说明文档,包括 API 配置方法、系统提示词的设计、JSON 数据结构的规范等,让任何拿到项目的人都能快速上手。
2.3 技术选型的考量
确定了目标之后,技术选型几乎是水到渠成的:
- 网页三件套(HTML5 + CSS3 + JavaScript):我特意没有选择 React、Vue 这类框架,因为对于一个单页的对话应用来说,原生 JavaScript 完全够用,而且零依赖意味着零构建、零打包,打开浏览器就能跑,这是最"轻"的方案。
- GitCode AI API:这是一个 OpenAI 兼容格式的接口,调用方式简单,支持真正的流式(SSE)返回,这对于实现"打字机"效果的对话体验至关重要。
- DeepSeek-V4-Flash 模型:这个模型在中文理解、结构化输出方面的表现让我满意,而且响应速度快,非常适合对话场景。
三、系统提示词的设计:让 AI 学会"说人话、办人事"
3.1 为什么需要系统提示词
大模型是一个非常强大的"通才",但如果你不给它明确的指令,它会用最"通用"的方式回答问题。比如你问它"帮我规划一个北京三日游",它可能给你写一篇洋洋洒洒的散文,从北京的历史讲起,一直讲到美食文化——好看是好看,但不实用。
我们真正想要的是:一份可以直接照着执行的行程计划,数据结构完全可控,字段完全可预期。要实现这一点,唯一的办法就是通过**系统提示词(System Prompt)**告诉模型"你的身份是什么、你的任务是什么、你必须用什么格式返回结果"。
3.2 提示词的完整设计
我为项目设计了一份相当详细的系统提示词,它包含三个核心部分:
第一部分是身份与任务设定。我告诉模型:“你是一位资深的旅行规划师,擅长设计个性化、可执行的旅游路线。你的任务是:根据用户提出的目的地、天数、预算等要求,输出一份完整的旅游行程规划。”
第二部分是输出规则约束。这是整个提示词最关键的部分:“只输出一个 JSON 对象,不要输出任何解释、前言、备注或 Markdown 代码块标记。JSON 必须严格符合下面的结构,字段不要遗漏,不要额外增加字段。内容使用简体中文。”
第三部分是JSON 结构定义。我把期望的数据结构完整地写在了提示词里,让模型照着"填空":
{
"destination": "目的地名称",
"days": 3,
"budget": "约2000元/人",
"travelType": "城市观光/休闲度假/美食之旅等",
"summary": "行程总览,2-3句话",
"itinerary": [
{
"day": 1,
"title": "当天主题",
"spots": [
{"name": "景点名称", "desc": "一句亮点介绍", "duration": "建议游玩时长"}
],
"food": ["推荐美食"],
"transport": "当天交通建议",
"hotel": "住宿区域建议",
"tips": "当天小贴士"
}
],
"foodRecommendations": ["本地必吃"],
"transportTips": ["交通提示"],
"budgetBreakdown": {
"交通": "xxx元", "住宿": "xxx元", "餐饮": "xxx元", "门票": "xxx元", "其他": "xxx元"
}
}
3.3 其中的设计智慧
这套提示词不是随手写的,里面包含了很多讲究:
其一,接口契约思维。 我把 JSON 结构看成前后端之间的"接口约定"。模型是"后端",前端页面是"消费者"。只要约定清晰,模型返回的数据就能被前端代码稳定地解析和渲染,而不需要做大量的兜底容错。
其二,字段的可渲染性。 你会发现,这里的每一个字段都是"为渲染而生"的:destination 会显示在大标题位置,travelType 会变成彩色标签,spots 是景点卡片,budgetBreakdown 是预算表格。数据结构的设计直接决定了 UI 的上限。
其三,约束的艺术。 提示词里用了"严格"、“必须”、"不要"这样强约束的词汇,同时给出了明确的默认规则:"如果用户信息不足(比如没提到天数或预算),请给出合理的默认建议并在 summary 中说明。"这样既保证了输出格式的稳定性,又不至于让模型在面对不完整输入时不知所措。
可以说,这份系统提示词是整个项目的地基。地基打得好不好,直接决定了上层建筑的美观程度。
四、Python 参考实现:接口调用的"标准答案"
在动手写 JavaScript 之前,我们先看一段 Python 的参考实现。这段代码来自 GitCode AI 官方接口的示例,它清晰地展示了这个接口的核心调用方式:
import os
import requests
import json
API_URL = "https://api-ai.gitcode.com/v1/chat/completions"
headers = {
"Authorization": f"Bearer camJ2D2RZfRNFRs9F5ERaLnj",
}
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:").rstrip("/n"))
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"])
这段代码虽然简短,但信息量很大。我们可以从中拆解出几个关键点:
第一,接口的调用形态。 这是一个标准的 OpenAI 兼容的 chat completions 接口。通过 POST 请求把 model、messages、stream 等参数以 JSON 格式发送到指定 URL,接口就会返回 AI 的回复。
第二,流式返回的处理方式。 stream=True 意味着接口不是一次性返回完整结果,而是像流水一样一段一段地推送数据。在 Python 中,我们用 response.iter_lines() 逐行读取响应流。
第三,SSE 协议的数据格式。 流式返回的数据遵循 SSE(Server-Sent Events)协议:每一行以 data: 开头,后面跟着一段 JSON 数据;当收到 data:[DONE] 时,表示流式传输结束。这条协议规则,是后面所有工作的核心。
第四,对话消息的结构。 messages 是一个数组,里面的每一项都有一个 role(用户或系统)和一个 content(内容)。这是模型理解对话上下文的方式。
第五,生成参数的讲究。 temperature: 0.6 控制回答的随机性(数值越大越有创意,越小越严谨);top_p: 0.95 是核采样参数;max_tokens: 2048 限制返回的最大 token 数;thinking_budget: 2048 留给模型"思考"的空间。
理解了这段参考代码,JavaScript 版本的实现就有了清晰的蓝图。
五、核心攻坚:把 Python 流式调用"翻译"成 JavaScript
5.1 面临的核心难题
把 Python 代码移植到浏览器 JavaScript,最大的难点不在语言语法,而在于运行环境的差异。
在 Python 里,requests 库可以轻松发起 HTTP 请求,iter_lines() 可以直接边读边解析响应流。但在浏览器里,前端 JavaScript 没有 requests 库,只有 fetch API。而 fetch 的流式读取方式,和 Python 的 iter_lines() 完全是两套机制。
具体来说,前端 JavaScript 的流式请求需要用到两个 Web API:ReadableStream(可读流)和 TextDecoder(文本解码器)。fetch 返回的 response.body 是一个 ReadableStream,我们可以通过 getReader() 拿到一个读取器,然后用 reader.read() 方法循环读取数据块。
5.2 JavaScript 流式实现的完整思路
我设计的 JavaScript 实现思路如下:
第一步,发起流式请求。使用 fetch 发送 POST 请求,请求头和请求体和 Python 版本完全一致——Content-Type 设置为 application/json,Authorization 带上 Bearer Token,body 里是与参考代码相同的 JSON 参数。
第二步,创建读取器和解码器。拿到 response.body 之后,调用 getReader() 创建 ReadableStreamDefaultReader,同时创建一个 TextDecoder("utf-8") 用于把二进制数据解码成可读字符串。
第三步,循环读取数据块。在 while 循环中不断调用 reader.read(),直到 done 为 true。每次读到的 value(一个 Uint8Array)通过 decoder.decode(value, { stream: true }) 解码成字符串,追加到缓冲区。
第四步,按行切分 SSE 数据。把缓冲区按换行符切成若干行,最后一行可能是半截数据,留到下个循环再处理。这是所有 SSE 解析器都会用到的一个技巧——"留尾巴"处理。
第五步,逐行解析。对每一行数据,判断是否以 data: 开头,是则提取后面的内容。如果内容是 [DONE],说明流式传输结束;否则尝试用 JSON.parse 解析,从中取出 choices[0].delta.content——这就是每次增量返回的那一小段文本。
5.3 代码实现的细节
下面是我在项目中写的核心请求函数的一部分。为了让大家看得更清楚,我把它做了适当的精简和标注:
const resp = await fetch(CONFIG.apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + CONFIG.apiKey
},
body: JSON.stringify({
model: CONFIG.model,
messages: [{ role: "system", content: SYSTEM_PROMPT }, ...messages],
stream: true,
max_tokens: CONFIG.params.max_tokens,
temperature: CONFIG.params.temperature,
top_p: CONFIG.params.top_p,
frequency_penalty: CONFIG.params.frequency_penalty,
thinking_budget: CONFIG.params.thinking_budget
})
});
const reader = resp.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let doneFlag = false;
while (!doneFlag) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let lines = buffer.split("\n");
buffer = lines.pop();
for (const raw of lines) {
const line = raw.trim();
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (data === "[DONE]") {
doneFlag = true;
break;
}
const chunk = JSON.parse(data);
const content = chunk.choices[0].delta.content;
if (content) {
fullText += content;
bubble.textContent = fullText;
}
}
}
5.4 与 Python 参考实现的一一对应
为了验证"翻译"的忠实度,我专门做了一张对照表,把 Python 和 JavaScript 的实现一一对应起来:
| Python (requests) | JavaScript (本工程) |
|---|---|
requests.post(..., stream=True) | fetch(url, { method: 'POST', body: ... }) |
resp.iter_lines() 逐行读取 | ReadableStream + TextDecoder 按行切分 |
line.startswith(b"data:") | line.startsWith("data:") |
data:[DONE] 结束标志 | "[DONE]" 结束标志 |
json.loads(line[6:]) 解析 chunk | JSON.parse(data) 解析 chunk |
yield 生成器逐个返回 | 在循环中逐块追加并按需渲染 |
可以看到,虽然语言不同、API 不同,但底层的数据流转逻辑是完全一致的。这也印证了一句话:技术栈可以变,思路是通的。
5.5 一个关键的网络常识
在实现过程中,我需要对浏览器能否直接调用这个 AI 接口有一个清醒的认识。由于浏览器存在跨域(CORS)限制,默认情况下,一个页面只能请求自己同源的资源。所以我在 README 中特别注明:如果遇到跨域问题,需要通过本地静态服务器访问页面,或者通过配置允许跨域的代理来解决。
实际测试下来,GitCode AI 接口在浏览器直连的情况下是可以正常工作的,这一点让项目得以保持"纯前端"的形态。
六、数据的"最后一公里":从流式文本到 JSON 对象
6.1 为什么要做 JSON 提取
当我看到 AI 完整地吐出一大段文本之后,我意识到事情还没有结束。虽然系统提示词要求模型"只输出 JSON",但由于大模型本身的特性,实际返回的内容可能会带上一些"杂质"——比如代码块标记、前置说明、思考痕迹等。
为了让前端拿到的是干净、可用的 JS 对象,我专门编写了一个叫做 extractJSON 的工具函数,负责把 AI 的原始输出"清洗"成规范的对象。
6.2 提取算法的设计
这个函数的处理逻辑分为三步:
第一步,剥离 Markdown 代码块。AI 有时会把 JSON 包在 ```````json ````这样的代码块里,所以我会先用正则匹配出代码块内的内容。正则表达式 /```(?:json)?\s*([\s\S]*?)```/ 可以同时匹配带和不带 json 标识的代码块。
第二步,截取 JSON 主体。用 indexOf("{") 找到第一个左花括号的位置,用 lastIndexOf("}") 找到最后一个右花括号的位置,然后截取这两个位置之间的内容。这个"取首尾"的策略非常鲁棒,即使 JSON 前面有解释性文字、后面有补充说明,也不影响提取结果。
第三步,JSON.parse 解析。把提取出来的字符串交给 JSON.parse。如果解析成功,返回干净的对象;如果失败,返回 null,由调用方决定兜底策略。
function extractJSON(text) {
let t = String(text || "").trim();
const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/);
if (fence) t = fence[1].trim();
const start = t.indexOf("{");
const end = t.lastIndexOf("}");
if (start === -1 || end === -1 || end <= start) return null;
try {
return JSON.parse(t.slice(start, end + 1));
} catch (e) {
return null;
}
}
6.3 优雅的降级策略
这里有一个重要的设计原则:任何情况都不能让用户看到白屏。
如果 JSON 解析成功并且包含 itinerary 字段,前端就把数据渲染成漂亮的行程面板;如果解析失败(比如模型因为某种原因没有按格式输出),前端就把原始文本原封不动地展示在气泡里,保证用户至少能看到内容。
这种"成功则渲染卡片、失败则保留原文"的双轨策略,让应用的健壮性大大提升。即使某次模型"不听话",用户也不会得到空白或者报错,体验不至于中断。
七、让数据"可视化":前端渲染的两重精彩
7.1 对话流与行程面板的巧妙结合
这个项目在界面上最大的亮点,是普通对话气泡与行程规划面板的共存。AI 的回复不再是单一形态的文本,而是可以"变身"的:在流式输出的过程中,它是一段正在"打字"的文本(相当于给用户一个期待的缓冲);输出完成后,如果成功解析出 JSON,它就会"变身"成一张结构丰满的行程总览图。
这种分段式的展示节奏,是我反复体验后觉得最自然的方式:先用流畅的流式文字让用户感到"AI 正在为我思考",再用精美的卡片让用户感到"AI 真的帮我办成了事"。
7.2 行程面板的结构设计
我把行程面板设计成了四层结构,每一层都有明确的信息定位:
第一层是行程头图(Hero)。这是一个漂亮的渐变色横幅,显示目的地名称、天数标签、预算标签和旅行类型标签。用户在一眼看过去的时候,就能立刻把握这次旅行的"基本信息"。
第二层是行程总览(Summary)。这里是 AI 用两到三句话概括的这次旅行的亮点和特色,让用户对整个行程有个宏观印象。
第三层是每日行程(Itinerary)。这是整个面板的主体,每一天是一个独立的卡片,包含 DAY 序号徽章、当天主题、景点清单(每个景点带简介和游玩时长)、推荐美食、交通建议、住宿区域和贴心提醒。
第四层是预算面板(Budget)。这是一个网格布局的预算分配表,把交通、住宿、餐饮、门票、其他各项的开支清晰地列出来,让用户对花费心里有数。
7.3 渲染中的工程细节
在实现渲染函数时,有两个细节值得分享:
一个是HTML 转义。AI 返回的数据是动态内容,直接拼进 HTML 会带来 XSS 安全风险。所以我写了一个 esc 函数,对所有动态插入的字符串做了转义处理,保证安全。
另一个是结构化与兼容性的平衡。在景点渲染这个环节,我最初的设计是简单的字符串标签。后来我进一步优化,把景点升级成了对象格式,包含 name(景点名)、desc(一句话亮点介绍)、duration(建议游玩时长)三个字段,渲染成更丰富的信息条。同时,我保留了向后兼容的能力——如果 AI 返回的是旧的纯字符串数组,前端也能正常渲染。这种"宽进严出"的兼容策略,让系统提示词的升级不至于破坏已有的对话记录。
7.4 视觉与体验的打磨
在视觉设计上,我做了一些刻意的追求:
- 配色体系:主色选用科技感的蓝色,强调色用充满生机的绿色,整体背景是蓝绿色渐变,传达"旅行+智能"的品牌感觉。
- 玻璃拟态:顶栏和输入区使用了半透明背景加
backdrop-filter: blur(),营造现代、轻盈的质感。 - 细腻动画:消息出现时有上浮渐入的动画,加载中有三个跳动的小圆点,按钮悬停有缩放和阴影效果,这些细节极大地提升了交互的手感。
- 响应式布局:桌面端和手机端都有良好的显示效果。在窄屏下,快捷示例从两列变成单列,消息气泡的最大宽度也会自动收缩,保证小屏幕上的可读性。
八、完整的用户体验设计
8.1 界面布局
整个页面采用"上中下"三段式布局:
- 顶部是品牌栏,左侧是项目 Logo 和名称,右侧是"新对话"按钮,可以随时清空上下文、开始一次全新的规划。
- 中部是聊天消息区,所有对话内容都在这里滚动展示。
- 底部是输入区,包含一个可以自动增高、支持换行的多行输入框,以及一个发送按钮。
初次进入页面时,消息区显示的是一个欢迎屏,中央有一个指南针图标,配上一句"想去哪里?我来帮你规划",下面还有四个快捷示例按钮:北京三日游、成都美食之旅、三亚亲子休闲游、西安历史文化游。用户甚至不需要打字,点一下快捷示例就能立刻体验。
8.2 交互细节
在交互层面,我特别注意了几个体验细节:
自动增高输入框。输入框会根据内容自动增高,最多不超过一定高度,避免大段文字时输入框被截断。
Enter 发送、Shift+Enter 换行。这是对话类应用的标配交互,既高效又符合用户直觉。
发送状态管理。当一个问题正在生成回答时,发送按钮会进入禁用状态,防止用户在流未结束前重复提交导致逻辑混乱。
多轮上下文记忆。项目维护了一个 messages 数组,保存完整的对话历史。每次新请求都会携带历史消息发给模型,让 AI 能理解用户后续的追问。比如用户说"帮我把第三天的行程改成海边",AI 结合历史就能精确定位到第三天的行程进行修改。
8.3 错误处理
网络请求是脆弱的,所以我做了完整的错误处理:
- 如果 HTTP 状态码不是 200,会读取错误响应体的内容,把状态码和错误信息一起展示给用户。
- 如果流式传输结束但没有任何内容,会提示"模型未返回内容"。
- 所有错误都会显示在 AI 的气泡中,并附带"请检查网络或 API 配置"的提示,方便用户定位问题。
九、测试:让每一次对话都有据可依
项目写完不是终点,测试才是交付质量的关键关卡。
9.1 静态检查
项目的主要代码用 JavaScript 编写,我首先使用了 Node.js 的语法检查工具对 config.js 和 app.js 做了静态校验,确保没有语法错误:
node --check js/config.js
node --check js/app.js
两个文件都顺利通过。
9.2 端到端接口测试
光有语法检查还不够,最关键的是验证 AI 接口能否真正返回我们期望的结构化 JSON。为此,我编写了一个临时测试脚本,模拟前端的完整请求流程:带着系统提示词和示例问题(“帮我规划一个成都 5 天美食之旅,预算 3000 元”)调用接口,收集完整的流式输出,再用 JSON 解析验证结果。
测试结果非常理想:
- HTTP 状态码 200,接口正常响应;
- 流式传输收到大量增量 chunk,最终拼成完整的回复文本;
- JSON 解析成功,包含
destination、days、budget、travelType、summary、itinerary、foodRecommendations、transportTips、budgetBreakdown全部字段; - 惊喜的是,
itinerary[0].spots[0]已经按新格式返回了{"name":"宽窄巷子","desc":"由宽巷子、窄巷子、井巷子组成的清代古街区,感受成都慢生活与地道小吃","duration":"2小时"}这样的丰富数据。
这个测试结果让我非常鼓舞。“宽窄巷子,成都慢生活,2 小时”——这正是我希望看到的那份透着"专业感"的答案。
9.3 兼容性测试
我还专门验证了新旧两种 spots 数据格式都能在前端正常渲染,确保系统提示词升级不会破坏已有的功能。这种细致的兼容性测试,是一个成熟工程必不可少的环节。
十、项目结构与工程配套
10.1 简洁清晰的项目结构
整个项目的文件结构非常清晰,只有 5 个文件:
lvyouluxianguihuaAI/
├── index.html # 页面入口(聊天界面结构)
├── css/
│ └── style.css # 全部样式(含行程面板风格)
├── js/
│ ├── config.js # API 配置 + 系统提示词(JSON 返回格式约定)
│ └── app.js # 聊天逻辑、SSE 流式解析、JSON 渲染
└── README.md # 完整的项目说明文档
配置、逻辑、样式、页面、文档各司其职,没有一丝冗余。
10.2 开箱即用的快速启动
为了让拿到项目的人能最快跑起来,我在 README 里写了几种启动方式,最简单的方式足以让人惊叹——如果你不介意文件协议下的跨域限制,直接双击 index.html 用浏览器打开就能用;更稳妥的方式是起一个本地静态服务器:
python3 -m http.server 8080
然后浏览器访问 http://localhost:8080,输入"帮我规划一个云南昆明+大理+丽江的7日游,预算5000元",回车,等待流式回答结束,一张精美的行程规划图就会展现在眼前。
10.3 文档中的工程细节
README 中除了启动方法和 API 配置说明,我还特意加入了三块内容:
一是安全提示。由于 apiKey 属于敏感信息,为了演示方便放在了前端配置中,我明确提醒:在生产环境使用,建议把密钥收敛到服务端代理,避免直接暴露在浏览器代码中。这是负责任的做法。
二是系统提示词与 JSON 返回格式的完整说明,让后人能理解系统的"接口契约"是怎么设计的。
三是 Python 参考实现对照表,把"从 Python 到 JavaScript"的移植过程完全透明化,任何后续维护者都能看懂这次技术转换的逻辑。
十一、踩过的坑与积累的经验
开发过程中,我确实踩了一些有意思的坑,也总结了一些经验,分享出来与大家共勉。
11.1 坑一:流式数据的分包与"半行"
使用 ReadableStream 读取数据时,一个数据块里可能包含多个完整的 SSE 行,也可能包含一个不完整的"半行"。如果直接把每次读到的数据当作一行来解析,就会遇到 JSON 解析失败的问题。
解决方案:维护一个缓冲区,每次读到的数据都追加进去;然后按换行符切分,把最后一段可能不完整的"尾巴"留到下一轮再处理。这是处理网络流的标准模式,一定要记牢。
11.2 坑二:do 模型的"自由发挥"
虽然系统提示词三令五申"只输出 JSON",但模型有时还是会在 JSON 外面包上 Markdown 代码块,或者在前面加上一段"好的,为您生成的行程如下:"。如果不做处理,JSON.parse 就会失败,导致渲染降级成纯文本。
解决方案:用 extractJSON 函数做清洗,先剥离代码块,再截取首尾花括号之间的内容。这套"鲁棒提取"逻辑,让解析的成功率大幅提高。
11.3 坑三:跨域(CORS)问题的隐忧
浏览器直连 AI 接口,跨域是绕不开的话题。虽然实测中接口工作正常,但如果在其他部署环境下遇到跨域被拦截,页面就会陷入白屏。
解决方案:在文档中明确提示使用本地静态服务器,并在错误处理中给出"请检查网络或 API 配置"的友好提示;同时,把 apiKey 放在 config.js 中方便替换,也预留了后端代理的方案。
11.4 经验一:接口契约是结构化的灵魂
这次项目让我深切体会到,和 AI 打交道最有效的姿势不是"发问",而是"立规矩"。把期望的数据结构写进系统提示词,并且写得越具体、越明确,模型的输出就越稳定。这本质上是把"大模型的不可控"转化为"接口的可控"。
11.5 经验二:降级设计是前端的生存之道
任何依赖第三方接口的应用,都要做好降级的心理准备。数据格式变了、超时了、接口返回异常了——这些情况都可能发生。永远为"最坏情况"留一条退路,用户的体验才有保障。
11.6 经验三:测试要"真枪实弹"
静态检查和模拟测试都不如一次真实的接口调用来得实在。我在开发中使用一个临时脚本真实调用了一次接口,亲眼看到 AI 按约定返回了完整结构、丰富内容的 JSON,心里那块石头才算真正落了地。
十二、项目成果一览
在这个项目里,我把"对话、数据、渲染"三个环节打通了:
在对话层面,用户可以用自然语言表达旅行需求,AI 作为"旅行规划师"给出专业回答,整个过程类似和真人顾问聊天。
在数据层面,AI 的输出是严格的结构化 JSON,字段可预期、格式可校验,相当于一台"生成数据的机器"。
在渲染层面,JSON 被转换为层次分明、信息丰富的行程面板——目的地头图、行程总览、每日行程卡片、预算分配表格,一应俱全。
我还为项目配备了完善的工程配套:代码结构清晰、文档齐备、启动方式简单、安全提示到位,并且通过了真实接口的端到端验证。
打开页面,点击"成都 5 天 4 晚美食之旅",等待十几秒,你就能看到一张这样的行程单:第一天到达成都,主题是"初识蓉城,开启味蕾之旅",宽窄巷子、春熙路在列,配着"感受成都慢生活""购物美食核心区"的介绍和时长建议;晚上住春熙路附近,交通提示是地铁直达;预算面板上,交通、住宿、餐饮、门票、其他各项花费一目了然。这份行程单的专业程度,不亚于任何一家旅行社的定制服务。
十三、未来的可能性
虽然项目已经完成,但在我看来,它更像是一个充满想象力的起点。如果继续迭代,我能想到很多激动人心的方向:
加地图联动。把景点坐标写入 JSON,前端接上高德地图,行程里的每个景点都可以直接跳转定位,步行、驾车路线一键导航。
加实时天气。接入天气 API,出行前自动在行程中加入当天的天气提醒和穿衣建议。
加预算动态调整。用户可以拖动预算滑块,AI 根据预算自动调整住宿档次、景点数量、餐饮选择,形成"预算驱动"的动态规划。
加行程导出。一键导出为 PDF 或图片,方便打印携带,或分享给同行的小伙伴。
加多城市智能衔接。对于跨城市的行程,自动计算城市间的交通时间和费用,给出最合理的动线方案。
加历史记录。把每次生成的行程保存到本地,形成自己的"旅行足迹"库,随时回顾、复用。
十四、写在最后
回顾整个项目,从最初一个"能不能让 AI 帮我做行程"的念头,到最后变成一个有完整视觉、完整交互、完整文档、经过真实测试的落地应用,这中间的每一步都让人收获满满。
如果让我用一个词概括这次开发的核心心得,那就是**“契约”**。与 AI 的对话要立契约,请它按 JSON 结构输出;与数据的解析要讲契约,前端严格按约定渲染;与用户的交互也要守契约,让每一次对话都有始有终。当契约确立了,复杂的大模型就变成了可控的生产工具。
技术没有多么高深,无非是 HTML、CSS、JavaScript 加上一个 AI 接口;思路也没有多么神奇,无非是"让它规范输出、我再漂亮展示"。但就是这些朴素的组合,最终变成了一件真正有用的东西——这大概就是"码道"给开发者最大的乐趣:把想法变成代码,把代码变成作品,把作品变成价值。
希望这篇博客能给你一些启发。如果你也想做一个 AI 驱动的应用,不妨从和 AI "立规矩"开始——设计好你的系统提示词,约定好你的输出结构,然后,放心地把剩下的交给想象力。
祝你也能码出属于自己的作品。
更多推荐


所有评论(0)