在这里插入图片描述
码道生成:
在这里插入图片描述

一、写在前面

如果你是一个经常出行的旅行爱好者,大概都经历过这样的时刻:打开搜索引擎,在"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:]) 解析 chunkJSON.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 "立规矩"开始——设计好你的系统提示词,约定好你的输出结构,然后,放心地把剩下的交给想象力。

祝你也能码出属于自己的作品。

Logo

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

更多推荐