← 八个项目 · 项目 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 把"人设"和"用户输入"清晰分开处理,比拼字符串稳得多 —— 这是上下文工程的入门。
需要什么?
- 项目 03 做的
index.html(或全新一个文件) - Ollama(详见入门手册)
- 不太老的电脑(8GB 内存能跑 3B 模型;16GB 能跑 7B)
怎么算"成"?
① 在你网页输入框打字 ② 点按钮 ③ 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>
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);③ 外部记忆(重要信息存数据库)。 进阶版会教这些。详见 概念地基 · 上下文工程。
动手沙盒
这一项你学到的 6 件事
- "调用 AI" 不是黑魔法 —— 一个 fetch + 一个 JSON 包就完事了。
- 本地 AI 一行命令就能跑 —— 不需要付费、不需要 API key、断网也能用。
- messages 数组是 AI 应用的"通用语言" —— 通义千问、DeepSeek、Ollama 全都用同一个格式。学会一种 = 学会全部。
- system prompt 决定产品定位,user message 是单次输入。分开传比拼字符串稳得多。
- async/await 是 AI 应用的标配 —— 等 AI 时不卡死页面。
- 多轮对话只是把 messages 数组累积起来。这就是所有 AI 应用背后的全部逻辑。
const apiKey = "sk_xxx..."。把网页放到 GitHub Pages。会发生什么?- 没事,浏览器会把 API key 加密。
- GitHub Pages 会自动隐藏 sensitive 字符串。
- 非常危险。任何访客按 F12 都能看到 API key,然后用它调 GPT-4 烧光你的钱。3 小时就能让你信用卡欠费几千美元。
- 浏览器会弹窗警告。