你正在 中阶版 · ⚡ 代码俱乐部 · ← 回到学院 · 中阶版主页 · 总入口

← 八个项目 · 项目 04 of 8

项目 04 · 让网页"直接"调 AI

项目 03 是"复制 prompt → 跳转 Qwen"。这一节升级:网页直接调本地的 AI,访客不用再开第二个标签。从此你做的就是真正的 AI 应用

这一节的工业意义。2025 年所有的 AI 产品(通义千问、DeepSeek、通义千问网页版、Cursor、Notion AI...)背后都是这同一件事: 前端 fetch → AI API → 解析 response → 显示。这一节做完,你已经写了一个通义千问的"最简版本"。 区别只是用的是哪个 model(我们用免费本地的)和怎么呈现(我们用最简 textarea)。

动手前 · 4 个核心概念

API(应用接口)

API = 程序之间互相调用的"窗口"。Ollama 跑起来后,会在你电脑上开一个本地 API 服务器,地址 http://localhost:11434。你的网页用 fetch() 给它发一个 JSON 包(带 prompt + system prompt),它返回 AI 生成的 JSON 包(带 response)。

API Key 和"本地 vs 云"的对比

付费云 AI(通义千问、DeepSeek、文心)调用都需要 API key —— 一串只有你才有的"密码字符"。如果你写在前端代码里,任何人 F12 都能偷,可能把你的钱花光。

本地 Ollama 不需要 API key,因为 AI 跑在你自己电脑上。所以本节用 Ollama 是合理的工程选择,不仅是因为免费 —— 也是因为安全

③ async / await(异步)—— "等 AI 想,但页面不卡死"

AI 生成回答需要 1-30 秒。如果你写普通同步代码,浏览器会"假死"那么久 —— 用户体验灾难。

async/await 让代码"等待 AI 时不阻塞 UI":用户依然能点击别的按钮、滚动页面,AI 准备好后再显示结果。所有 AI 应用必备。

System prompt 在 API 里怎么传

项目 03 我们把 system prompt 和用户问题拼成一段。专业做法是分开传 —— Ollama API 支持 messages 数组,标记每段是 system 还是 user

{
  "messages": [
    {"role": "system", "content": "你是一个北京爷爷..."},
    {"role": "user",   "content": "糖醋排骨怎么做?"}
  ]
}

这样 AI 把"人设"和"用户输入"清晰分开处理,比拼字符串稳得多 —— 这是上下文工程的入门。

需要什么?

怎么算"成"?

① 在你网页输入框打字 ② 点按钮 ③ 5 秒内 AI 直接在网页上显示回答 —— 不开第二个标签、不用复制粘贴。④ 整个过程不联网也能跑(拔了网线测试)。

步骤 1 · 启动 Ollama 本地服务

装好 Ollama 之后,命令行运行:

ollama run qwen2.5:3b

第一次会下载约 2GB(建议在家里 WiFi 下载)。下载完它会自动启动一个本地 API 服务,地址 http://localhost:11434这个窗口要一直开着 —— 关了 AI 服务就停了。

验证启动成功:另开一个命令行窗口跑:

curl http://localhost:11434/api/tags

看到一个 JSON 包列出已下载的模型 —— 说明服务在跑。

步骤 2 · 一个完整可跑的 AI 网页

把下面的代码保存为 ai-app.html。这是升级版的项目 03 —— 用 messages 数组、分离 system prompt、显示"思考中"动画、出错有友好提示。下面把它拆成 6 块讲,每块都有"这段干啥"和"试改一下"。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>爷爷的菜谱小助手(直连版)</title>
<style>
  body { font-family: sans-serif; max-width: 600px; margin: 3rem auto;
         padding: 0 1.5rem; background: #FAF4DF; color: #1F2230; }
  h1 { color: #DC5C44; }
  textarea { width: 100%; min-height: 80px; padding: .8rem;
             border: 2px solid #1F2230; border-radius: 8px;
             font-family: inherit; font-size: 1rem; box-sizing: border-box; }
  button { padding: .7rem 1.4rem; background: #DC5C44; color: white;
           border: none; border-radius: 999px; font-size: 1rem;
           font-weight: 700; cursor: pointer; margin-top: .7rem; }
  button:disabled { background: #888; cursor: wait; }
  .output { margin-top: 1.5rem; padding: 1.2rem; background: #FFFCF0;
            border: 2px dashed #1F2230; border-radius: 8px;
            min-height: 80px; white-space: pre-wrap; line-height: 1.6; }
  .err { color: #B43D2C; font-weight: 700; }
</style>
</head>
<body>

<h1>🍳 爷爷的菜谱小助手</h1>
<p>问爷爷怎么做某道家常菜 —— 用爷爷的口吻直接回答。</p>

<textarea id="question" placeholder="例:糖醋排骨怎么做?"></textarea>
<button id="ask" onclick="askAI()">问爷爷 →</button>

<div class="output" id="output">
  (这里会显示 AI 的回答。第一次响应可能要 5-15 秒,因为模型刚加载。)
</div>

第 1 块 · HTML 骨架 + CSS 皮肤(和项目 03 一样)

这一段跟项目 03 的网页几乎一模一样:标题、说明、输入框、按钮、显示结果的盒子。

新东西:按钮多了一个 onclick="askAI()" —— 这是直接告诉浏览器"被点击时调名字叫 askAI 的函数"。比项目 03 的 addEventListener 写法更短,但功能一样。

CSS 也多了一行 button:disabled { background: #888; cursor: wait; } —— 当我们在等 AI 回答时按钮会"灰掉 + 变沙漏",告诉用户"请等等"。

👉 试改:placeholder<h1> 标题、和按钮文字换成你自己的项目主题。

<script>
const SYSTEM_PROMPT = `你是一个 70 岁北京爷爷,专门教别人做家常菜。
回答规则:
1. 用爷爷的口吻 —— 朴素、温暖、爱说"嗯......"、"你听我说"
2. 不给精确克数,给"大概多少"+"怎么判断"
3. 重视"看锅"、"听声音"、"闻味道"
4. 没听过的菜就说"哎呀这个爷爷我没做过"
5. 答案不超过 200 字`;

第 2 块 · System prompt · 你网页的"灵魂"

这就是 AI 的"人设说明书"。每次问 AI,这一段都会被悄悄塞到 AI 看到的最前面 —— AI 整段回话都按这个性格、规则、风格回。

反引号 `...` 包字符串,可以跨多行 —— 普通引号 '" 不能跨行。

💡 核心认知:HTML/CSS 决定网页"长什么样",但 SYSTEM_PROMPT 决定网页"是什么"。同一份 HTML 配 100 种不同的 SYSTEM_PROMPT,能做出 100 种完全不同的产品。这是 AI 应用最重要的资产。

👉 试改:把这一段全部替换成"你是一个 9 岁的恐龙小专家……",配上 5 条恐龙规则。整个网页瞬间变成"恐龙问答机",没改一行其他代码。

async function askAI() {
  const q = document.getElementById('question').value.trim();
  if (!q) { alert('先写个问题!'); return; }

  const btn = document.getElementById('ask');
  const out = document.getElementById('output');

  btn.disabled = true;
  btn.textContent = '爷爷想想...';
  out.textContent = '思考中......';

第 3 块 · 函数开头 · 拿用户输入 + 显示"思考中"

async function askAI() { ... } = "造一个名字叫 askAI 的异步函数"。async 表示这个函数里面会有"等待"的事情发生(等 AI 回答)。

头三行:找输入框 → 拿用户写的字 → 去掉前后空格 → 如果空就提醒并退出。

接下来三行:把按钮变灰、按钮文字改成"爷爷想想..."、显示框写"思考中......"。这三行的目的是 UX —— 用户点了按钮立刻看到反馈,知道 AI 在干活,不会以为没反应。

💡 这就是"用户体验设计":专业 AI 应用 = 立即给用户反馈("我听到了")+ 让人知道发生了什么("我在想")。少了这两步,用户会反复点按钮。

👉 试改:把"爷爷想想..."改成'🤔 嗯...让爷爷想想'(加表情,让 UX 更有人味)。

  try {
    const res = await fetch('http://localhost:11434/api/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        model: 'qwen2.5:3b',
        messages: [
          { role: 'system', content: SYSTEM_PROMPT },
          { role: 'user',   content: q }
        ],
        stream: false
      })
    });

第 4 块 · 调本地 AI · 整段最重要的代码

try { ... } catch { ... } 是"试试看,错了别崩溃"的写法。万一本地 Ollama 没启动 / 模型没下 / 网络断了,AI 不会让整个页面挂掉,会跳到 catch 给友好提示。

fetch('http://localhost:11434/api/chat', { ... }) = "给本地 11434 端口(Ollama 默认端口)发一个 HTTP 请求"。http://localhost 就是"我自己这台电脑"的意思。

method: 'POST' = "我要发一包数据过去"(不是 GET 拿东西)。headers = "标记我发的是 JSON 格式"。

最关键的 body

  • model: 'qwen2.5:3b' = 用哪个本地模型。改成 'llama3.2''deepseek-r1:1.5b' 或用通义千问网页版都行。
  • messages: [...] = 一个数组,里面是"对话历史"。每条标 role(system / user / assistant)+ content(内容)。
  • stream: false = "等全部生成完再一起返回"。如果改 true,AI 会一个字一个字蹦出来(更高级,但代码要改)。

await = "等这个 fetch 做完再继续"(可能 2-30 秒)。await 必须在 async 函数里用。

💡 这就是"AI 应用通用语言":通义千问、DeepSeek、智谱清言、Ollama 全都用同一个 messages 数组格式。学会这一段 = 学会调任何 AI。

👉 试改:在 messages 数组里加一条 { role: 'assistant', content: '我准备好了,问吧。' } 在 user 之前 —— 这就是给 AI 一个"虚假的开场",引导它的语气。这是 prompt 工程一个高级技巧。

    if (!res.ok) throw new Error('Ollama 服务返回错误:' + res.status);
    const data = await res.json();
    out.textContent = data.message.content;

  } catch (err) {
    out.innerHTML = '<span class="err">出错:' + err.message + '</span>'
      + '<br><br>最常见原因:① Ollama 没启动(命令行跑 ollama run qwen2.5:3b)'
      + ' ② 浏览器拒绝跨域请求(用 VS Code Live Server 插件打开 html,不要直接双击)。';
  } finally {
    btn.disabled = false;
    btn.textContent = '问爷爷 →';
  }
}
</script>

第 5 块 · 解析 AI 的回答 + 错误处理

if (!res.ok) throw new Error(...) = "如果 HTTP 状态码不是 200(成功),主动报错跳到 catch"。

const data = await res.json() = "把响应解析成 JavaScript 对象"。Ollama 返回的格式大致是: { "message": { "role": "assistant", "content": "AI 答的内容..." }, ... }

out.textContent = data.message.content = "把 AI 答的字写到显示框里"。

catch (err) { ... } = "出错时执行的代码"。这里展示用户友好的错误提示 —— 不光说"出错",还告诉用户最可能的原因和怎么修。

finally { ... } = "无论成功还是失败,都执行"。这里把按钮恢复成可点 + 文字改回 "问爷爷 →",让用户能再问下一题。

💡 工程师素养:新手只写"成功路径"代码 —— "应该不会出错吧"。专业代码 50% 都在处理"出错时该怎么办"。try/catch/finally 是 production 代码的标配。

</body>
</html>

第 6 块 · 收尾

关掉 <body><html> 标签。每个 HTML 文档以这两个结尾。

💡 整个网页就这么多。大约 70 行代码 —— 你已经做了一个 AI 应用。所有现代 AI 工具(通义千问、Cursor、DeepSeek)背后都是同样的逻辑:HTML 接收输入 → fetch 调 AI API → 解析 response → 显示。区别只是规模和工程化。

📋 看 / 复制完整代码(一键到位)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>爷爷的菜谱小助手(直连版)</title>
<style>
  body { font-family: sans-serif; max-width: 600px; margin: 3rem auto;
         padding: 0 1.5rem; background: #FAF4DF; color: #1F2230; }
  h1 { color: #DC5C44; }
  textarea { width: 100%; min-height: 80px; padding: .8rem;
             border: 2px solid #1F2230; border-radius: 8px;
             font-family: inherit; font-size: 1rem; box-sizing: border-box; }
  button { padding: .7rem 1.4rem; background: #DC5C44; color: white;
           border: none; border-radius: 999px; font-size: 1rem;
           font-weight: 700; cursor: pointer; margin-top: .7rem; }
  button:disabled { background: #888; cursor: wait; }
  .output { margin-top: 1.5rem; padding: 1.2rem; background: #FFFCF0;
            border: 2px dashed #1F2230; border-radius: 8px;
            min-height: 80px; white-space: pre-wrap; line-height: 1.6; }
  .err { color: #B43D2C; font-weight: 700; }
</style>
</head>
<body>

<h1>🍳 爷爷的菜谱小助手</h1>
<p>问爷爷怎么做某道家常菜 —— 用爷爷的口吻直接回答。</p>

<textarea id="question" placeholder="例:糖醋排骨怎么做?"></textarea>
<button id="ask" onclick="askAI()">问爷爷 →</button>

<div class="output" id="output">
  (这里会显示 AI 的回答。第一次响应可能要 5-15 秒,因为模型刚加载。)
</div>

<script>
const SYSTEM_PROMPT = `你是一个 70 岁北京爷爷,专门教别人做家常菜。
回答规则:
1. 用爷爷的口吻 —— 朴素、温暖、爱说"嗯......"、"你听我说"
2. 不给精确克数,给"大概多少"+"怎么判断"
3. 重视"看锅"、"听声音"、"闻味道"
4. 没听过的菜就说"哎呀这个爷爷我没做过"
5. 答案不超过 200 字`;

async function askAI() {
  const q = document.getElementById('question').value.trim();
  if (!q) { alert('先写个问题!'); return; }

  const btn = document.getElementById('ask');
  const out = document.getElementById('output');

  btn.disabled = true;
  btn.textContent = '爷爷想想...';
  out.textContent = '思考中......';

  try {
    const res = await fetch('http://localhost:11434/api/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        model: 'qwen2.5:3b',
        messages: [
          { role: 'system', content: SYSTEM_PROMPT },
          { role: 'user',   content: q }
        ],
        stream: false
      })
    });

    if (!res.ok) throw new Error('Ollama 服务返回错误:' + res.status);
    const data = await res.json();
    out.textContent = data.message.content;

  } catch (err) {
    out.innerHTML = '<span class="err">出错:' + err.message + '</span>'
      + '<br><br>最常见原因:① Ollama 没启动(命令行跑 ollama run qwen2.5:3b)'
      + ' ② 浏览器拒绝跨域请求(用 VS Code Live Server 插件打开 html,不要直接双击)。';
  } finally {
    btn.disabled = false;
    btn.textContent = '问爷爷 →';
  }
}
</script>

</body>
</html>
⚠️ 跨域问题(CORS):有些浏览器拒绝从 file://(双击打开的 html)调用 http://localhost。解决:在 VS Code 装 Live Server 插件,右键 html → "Open with Live Server" → 浏览器从 http://127.0.0.1:5500 打开 → 跨域问题消失。

步骤 3 · 改 system prompt 试试不同"AI 人设"

现在你有完整的 AI 应用骨架了。替换 SYSTEM_PROMPT 一段,就能做出截然不同的应用。例如:

变体 A · "翻译成 9 岁孩子能懂的话"工具
const SYSTEM_PROMPT = `你是个翻译机:把任何复杂的内容翻译成 9 岁孩子能听懂的话。
规则:
1. 不用专业词,用"力量"代替"能量"
2. 用孩子身边的东西做比喻
3. 字数不超过 100
4. 最后留一个让孩子继续问的小问题`;

这就是"为弟弟妹妹做的科学翻译器"

变体 B · "我刚才说话凶不凶"检测器
const SYSTEM_PROMPT = `你是同理心检查器。用户会贴一段他想发出去的话。
你必须:
1. 给一个 1-5 的"凶度评分"
2. 指出 2 个最可能让人不舒服的具体词或句子
3. 给一个更温和的版本(不改原意)

不要说教。只做评分 + 修改。`;

这就是努尔的"会不会显得很凶"项目的 v2。

变体 C · "诗歌评分官"(带 5 条标准)
const SYSTEM_PROMPT = `你是一个挑剔的诗歌读者。
评分 5 条:
1. 有没有具体的物件(不是抽象词)?(0-2 分)
2. 有没有出乎意料的转折?(0-2 分)
3. 字数控制在 8-20 字之间?(0-1 分)
4. 没用"美丽""快乐"这种空洞词?(0-2 分)
5. 最后留有余地?(0-3 分)

给每条打分 + 1 句解释,最后给总分(满分 10)。`;

这就是小薇的诗歌评分表的可执行版。

步骤 4 · 进阶:让 AI 看到"上下文"(多轮对话)

上面的代码每次问都是"重新开始"——AI 不记得你之前问过什么。要让它记住对话历史,把整个 messages 数组累积起来:

// 在 script 顶部加:
let history = [
  { role: 'system', content: SYSTEM_PROMPT }
];

async function askAI() {
  const q = document.getElementById('question').value.trim();
  if (!q) return;

  // 把用户的话加进 history
  history.push({ role: 'user', content: q });

  const res = await fetch('http://localhost:11434/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: 'qwen2.5:3b',
      messages: history,   // ← 把整个 history 一起发
      stream: false
    })
  });

  const data = await res.json();
  const aiReply = data.message.content;

  // 把 AI 的回答也加进 history(这样下一轮它能"记得"自己说过什么)
  history.push({ role: 'assistant', content: aiReply });

  // 显示
  document.getElementById('output').textContent = aiReply;
}

这就是多轮对话的核心机制。前端只做一件事:维护一个 messages 数组,每轮 push 用户消息和 AI 消息。

这就是"上下文工程"的入门。但 history 不能无限长 —— 每个模型都有上下文窗口上限(Qwen 2.5 约 32k token)。 真实产品要做:① 压缩历史(让 AI 总结前 10 轮);② 滑窗(只保留最近 N 轮 + 永久 system);③ 外部记忆(重要信息存数据库)。 进阶版会教这些。详见 概念地基 · 上下文工程

动手沙盒

动手 把上面的代码跑一下(在沙盒里能看到结构,但要 AI 真返回需要本机 Ollama)
任务:下面是个"模拟版" —— 没有真 AI,但你能改 SYSTEM_PROMPT,看你的"prompt + 用户输入"被组装成什么样的请求。这就是发到 Ollama 的真包。

这一项你学到的 6 件事

  1. "调用 AI" 不是黑魔法 —— 一个 fetch + 一个 JSON 包就完事了。
  2. 本地 AI 一行命令就能跑 —— 不需要付费、不需要 API key、断网也能用。
  3. messages 数组是 AI 应用的"通用语言" —— 通义千问、DeepSeek、Ollama 全都用同一个格式。学会一种 = 学会全部。
  4. system prompt 决定产品定位,user message 是单次输入。分开传比拼字符串稳得多。
  5. async/await 是 AI 应用的标配 —— 等 AI 时不卡死页面。
  6. 多轮对话只是把 messages 数组累积起来。这就是所有 AI 应用背后的全部逻辑。
小测 为什么不能在前端写 API key?
你做了一个用 GPT-4 的网页,在 JavaScript 里写了 const apiKey = "sk_xxx..."。把网页放到 GitHub Pages。会发生什么?
  • 没事,浏览器会把 API key 加密。
  • GitHub Pages 会自动隐藏 sensitive 字符串。
  • 非常危险。任何访客按 F12 都能看到 API key,然后用它调 GPT-4 烧光你的钱。3 小时就能让你信用卡欠费几千美元。
  • 浏览器会弹窗警告。
为什么第三个对:所有放进浏览器的代码(HTML/CSS/JS)都对访客可见。F12 就能看。 所以付费 AI API key 必须保存在"后端"(你自己跑的服务器),网页通过它间接调用。 用 Ollama 的根本好处:AI 在用户自己电脑上跑 —— 没有 key、不烧钱、还离线能用。

← 上一个 下一个:从看一个真人开始 →