知识点思维导图
30 个知识节点
生产工程(04) - 前端调用 AI 接口
读完后,你应能完成以下任务:
- 绘制“生产工程(04) - 前端调用 AI 接口 / 把一次调用拆成一组状态”的关键对象与数据流,解释“核心认知:成功只是其中一条路径。”,并用源码位置、日志或 Trace 标注证据。
- 为“生产工程(04) - 前端调用 AI 接口 / 三种失败,要分开处理”设计正常与异常输入,验证“finally 也常被忘——失败时忘了把禁用的按钮恢复,用户就再也发不出消息。”,输出首个偏差位置与回归测试结果。
- 实现“生产工程(04) - 前端调用 AI 接口 / 给用户一个重试入口”的最小代码或配置,检验“AI 接口失败是常态(模型偶发超时、限流)。”,输出命令、结果与 Diff,并说明不适用边界。
一、前端调用 AI 接口的真实应用场景
你给问答接口写了前端:
const res = await fetch("/api/chat", { method: "POST", body: JSON.stringify({ message }) });
const data = await res.json();
showAnswer(data.answer);
本地点了几下,能用。上线后用户反馈一堆问题:点了发送没任何反应,不知道在不在加载(模型要 3 秒);偶尔接口报错,页面直接卡住或显示 undefined;网络断了,整个页面假死。
问题在于:调 AI 接口不是「发请求拿结果」这么简单。模型慢、会失败、网络会断。一次 fetch 背后是一组用户能感知的状态,你得把每种状态都管起来。这恰好是前端的主场——你比谁都懂状态管理。
二、把一次调用拆成一组状态
像管理前端 store 一样,给这次交互定义清楚状态:
const state = {
status: "idle", // idle | loading | error
lastMessage: "", // 上一条输入,重试时复用
};
| 状态 | 什么时候 | 界面表现 |
|---|---|---|
idle |
没在请求 | 输入框可用,按钮正常 |
loading |
请求发出到拿到响应 | 按钮禁用变「生成中」、显示「思考中」占位 |
error |
任何一种失败 | 红色错误条 + 重试按钮 |
核心认知:成功只是其中一条路径。新手只写成功路径,老手先想清楚 loading 和 error 怎么展示。
三、三种失败,要分开处理
「失败」不是一种,是三种,处理方式不同:
try {
const res = await fetch("/api/chat", { ... });
const data = await res.json();
if (!res.ok) {
// 失败一:HTTP 状态非 2xx(后端明确返回了 4xx/5xx)
// 4xx 是请求本身有问题(比如缺字段),重试也没用,提示用户改输入
// 5xx 是服务端的问题,可以重试
showError(data.error);
return;
}
// 成功路径
showAnswer(data.answer);
} catch (err) {
// 失败二:网络层异常(断网、服务没起、跨域被拦)
// fetch 根本没拿到响应,直接 reject,进 catch
showError("网络异常,请检查连接");
} finally {
// 不管成功失败,都要恢复输入框可用,否则用户被永久锁住
syncUI();
}
很多人漏掉 catch 块,结果断网时 fetch reject、没人接,页面就假死了。finally 也常被忘——失败时忘了把禁用的按钮恢复,用户就再也发不出消息。
四、给用户一个重试入口
AI 接口失败是常态(模型偶发超时、限流)。失败时不能只甩个错误就完事,要让用户一键重试,而不是重新打一遍字:
function showError(reason) {
const div = appendMessage("error", "出错了:" + reason);
const retry = document.createElement("button");
retry.textContent = "重试";
retry.onclick = () => sendMessage(state.lastMessage); // 复用上次的输入
div.appendChild(retry);
}
state.lastMessage 存着上一条输入,重试直接复用。这个小细节决定了出错时用户是骂娘还是顺手点一下。
五、工程上真正会踩的坑
- 不处理网络层异常。只写
if (!res.ok),忘了catch。断网时 fetch reject 没人接,页面假死。两类失败都要处理。 - 失败后按钮一直禁用。loading 时禁用了按钮,但失败路径忘了恢复。用
finally兜底恢复 UI。 - API Key 写进前端。前端代码全公开,Key 必须在后端。前端只调用自己的后端接口,模型调用边界见大模型基础(07) - 大模型 API 基础。
- 4xx 也让用户重试。缺字段、格式错这种 4xx 重试多少次都一样。要区分 4xx(提示改输入)和 5xx(可重试)。
- 流式响应重复追加。如果改用 SSE,断连重连时容易把已显示的 token 再追加一遍,要去重(生产工程(04)《流式响应》)。
六、一句话面试答法
前端调用 AI 接口要处理好哪些点? 我把它当一组 UI 状态而不是一次 fetch:idle、loading、error,成功只是其中一条路径。失败要分三种——HTTP 4xx 是请求问题提示用户改输入、5xx 是服务端问题可重试、fetch reject 是网络层异常要单独 catch,很多人漏掉最后一个导致断网时页面假死。用 finally 兜底恢复被禁用的按钮,失败时给一键重试入口复用上次输入。长回答用 SSE 流式提升体验,注意断连重连别重复追加 token。Key 永远在后端。
七、动手实践:16 前端调用 AI 接口
浏览器里用原生 JS 调一个 AI 问答接口。重点不是「发一次 fetch」,而是把调用 AI 接口当成一组 UI 状态来管:loading、成功、客户端错误、服务端错误、网络异常,每种都要有对应的界面表现和恢复入口。配一个最小 Python 后端。
7.1 运行
python3 server.py
然后浏览器打开 **http://127.0.0.1:8016**,在输入框里试:
- 输入「报销」「请假」→ 看正常问答(有 0.6 秒 loading 态)
- 输入「报错」→ 触发服务端 500,看错误提示和「重试」按钮
零依赖,纯标准库。
7.2 预期输出
启动后终端:
服务已启动,浏览器打开:http://127.0.0.1:8016
试试输入「报销」「请假」,或输入「报错」看错误态。Ctrl+C 停止
后端接口的几种响应(也是前端要分别处理的几种情况):
GET / -> 200 返回 index.html
POST /api/chat {"message":"报销.."} -> 200 {"answer": "报销需在费用产生后 30 天内提交,附发票和审批单。", "error": null}
POST /api/chat {"message":"报错"} -> 500 {"error": "model_unavailable", "answer": null}
POST /api/chat {"message":""} -> 400 {"error": "missing_field", "answer": null}
服务端访问日志:
[访问] GET / -> 200
[访问] POST /api/chat -> 200
[访问] POST /api/chat -> 500
[访问] POST /api/chat -> 400
浏览器里的表现:发送时按钮变「生成中…」并禁用、显示「助手思考中…」占位;成功后占位被答案替换;输入「报错」则显示红色错误条 + 「重试」按钮,点重试自动复用上一条问题。
7.3 代码↔概念对应
| 概念 | 在哪里 |
|---|---|
| 用 state 集中管理请求状态 | index.html const state |
| loading 态(禁用按钮、显示占位) | syncUI、sendMessage 里的 thinking |
| 区分 HTTP 错误(4xx/5xx) | sendMessage 里 if (!res.ok) |
| 区分网络层异常 | sendMessage 的 catch 块 |
| 错误态 + 重试入口 | showError |
| 后端故意延迟让 loading 可见 | server.py time.sleep(0.6) |
| 后端模拟服务端错误 | server.py if message == "报错" |
7.4 动手改
- 把
sendMessage里的fetch改成生产工程(04)《流式响应》的 SSE 流式调用(new EventSource),体验打字机效果。 - 给 loading 态加一个「停止」按钮,用
AbortController中断 fetch。 - 故意把后端关掉,在页面发消息,看
catch块的「网络异常」提示——这就是前端必须处理网络层失败的原因。
八、总结
- 把一次调用拆成一组状态:核心认知:成功只是其中一条路径。
- 三种失败,要分开处理:finally 也常被忘——失败时忘了把禁用的按钮恢复,用户就再也发不出消息。
- 给用户一个重试入口:AI 接口失败是常态(模型偶发超时、限流)。
- 工程上真正会踩的坑:loading 时禁用了按钮,但失败路径忘了恢复。
- 一句话面试答法:我把它当一组 UI 状态而不是一次 fetch:idle、loading、error,成功只是其中一条路径。
8.1 实现源码与运行边界
下方 sandbox.html 可直接在文章中运行;其余文件保留真实本地项目结构,用于理解接口、部署和测试。
index.html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>AI 接口状态实验</title>
<style>
body { max-width: 680px; margin: 48px auto; padding: 0 20px; font: 16px/1.6 system-ui; }
form { display: flex; gap: 8px; }
input { flex: 1; padding: 10px; }
button { padding: 10px 16px; }
#result { margin-top: 20px; white-space: pre-wrap; }
</style>
</head>
<body>
<h1>AI 接口状态实验</h1>
<form id="chat-form">
<input id="message" placeholder="输入“报错”可测试服务端错误">
<button id="submit" type="submit">发送</button>
</form>
<div id="result" role="status">等待输入</div>
<script>
const form = document.querySelector('#chat-form'); // 问答表单。
const input = document.querySelector('#message'); // 问题输入框。
const button = document.querySelector('#submit'); // 提交按钮。
const result = document.querySelector('#result'); // 状态与结果区域。
form.addEventListener('submit', async (event) => {
event.preventDefault();
const message = input.value.trim(); // 清洗后的用户问题。
if (!message) {
result.textContent = '客户端校验:问题不能为空';
return;
}
button.disabled = true;
result.textContent = '请求中...';
try {
const response = await fetch('/api/chat', { // AI 接口响应。
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
const payload = await response.json(); // 解析后的响应对象。
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
result.textContent = `成功:${payload.answer}`;
} catch (error) {
result.textContent = `失败:${error.message},可以修改问题后重试。`;
} finally {
button.disabled = false;
}
});
</script>
</body>
</html>
sandbox.html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'none'; img-src data:"
/>
<title>流式回答状态机</title>
<style>
:root {
color-scheme: light dark;
font-family: ui-sans-serif, system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #101312;
color: #f4f6f5;
}
main {
min-height: 350px;
padding: 20px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
h1 {
margin: 0;
font-size: 16px;
letter-spacing: 0;
}
.status {
display: inline-flex;
align-items: center;
gap: 7px;
color: #a8b0ad;
font:
12px ui-monospace,
monospace;
}
.status::before {
width: 8px;
height: 8px;
border-radius: 50%;
background: #6ee7b7;
content: '';
}
.status[data-state='streaming']::before {
animation: pulse 1s infinite;
background: #fbbf24;
}
.status[data-state='cancelled']::before {
background: #fb7185;
}
.prompt-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
input {
min-width: 0;
border: 1px solid #353c39;
border-radius: 6px;
background: #171b19;
color: inherit;
padding: 10px 12px;
font: inherit;
outline: none;
}
input:focus {
border-color: #6ee7b7;
}
button {
border: 1px solid #3f4a45;
border-radius: 6px;
background: #202622;
color: inherit;
padding: 9px 13px;
cursor: pointer;
font: 13px inherit;
}
button.primary {
border-color: #6ee7b7;
background: #6ee7b7;
color: #07110d;
font-weight: 700;
}
button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.quick {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin: 10px 0 18px;
}
.quick button {
padding: 6px 9px;
color: #c5cdc9;
font-size: 12px;
}
.answer {
min-height: 150px;
border: 1px solid #2f3733;
border-radius: 6px;
background: #151917;
padding: 16px;
white-space: pre-wrap;
line-height: 1.7;
}
.answer:empty::before {
color: #727b77;
content: '回答会逐字显示在这里。';
}
.controls {
display: flex;
justify-content: space-between;
gap: 10px;
margin-top: 12px;
}
.meta {
color: #8b9590;
font:
12px ui-monospace,
monospace;
}
@keyframes pulse {
50% {
opacity: 0.35;
}
}
@media (max-width: 520px) {
.prompt-row {
grid-template-columns: 1fr;
}
.controls {
align-items: flex-start;
flex-direction: column;
}
}
</style>
</head>
<body>
<main>
<header>
<h1>流式回答前端</h1>
<span id="status" class="status" data-state="idle">IDLE</span>
</header>
<div class="prompt-row">
<input id="question" value="RAG 为什么需要重排?" aria-label="问题" />
<button id="send" class="primary" type="button">发送</button>
</div>
<div class="quick" aria-label="快捷问题">
<button type="button" data-question="什么是混合检索?">混合检索</button>
<button type="button" data-question="Chunk overlap 有什么用?">Chunk overlap</button>
</div>
<section id="answer" class="answer" aria-live="polite"></section>
<div class="controls">
<span id="meta" class="meta">0 chars · 0 ms</span>
<div>
<button id="cancel" type="button" disabled>取消</button>
<button id="retry" type="button" disabled>重试</button>
</div>
</div>
</main>
<script>
/** 不同问题对应的确定性离线答案,保证沙盒无需后端即可运行。 */
const ANSWERS = {
'RAG 为什么需要重排?':
'初排负责从大规模语料中快速召回候选,重排再用更精细的相关性模型调整 Top-K。这样既控制延迟和成本,也能减少关键词命中但语义不相关的内容进入上下文。',
'什么是混合检索?':
'混合检索同时使用 BM25 关键词召回与向量语义召回,再通过 RRF 或加权归一化合并排序。它兼顾精确术语、编号匹配和语义改写。',
'Chunk overlap 有什么用?':
'Overlap 在相邻文本块间保留少量重复内容,降低答案证据刚好跨越切分边界时的信息损失。重叠过大会增加索引体积、重复召回和 Token 成本。'
}
/** 问题输入框。 */
const questionInput = document.querySelector('#question')
/** 发送按钮。 */
const sendButton = document.querySelector('#send')
/** 取消按钮。 */
const cancelButton = document.querySelector('#cancel')
/** 重试按钮。 */
const retryButton = document.querySelector('#retry')
/** 回答展示区域。 */
const answerElement = document.querySelector('#answer')
/** 当前状态标签。 */
const statusElement = document.querySelector('#status')
/** 字符数与耗时展示区域。 */
const metaElement = document.querySelector('#meta')
/** 当前流式输出的计时器。 */
let streamTimer = null
/** 最近一次实际发送的问题,用于重试。 */
let lastQuestion = questionInput.value
/**
* 更新完整 UI 状态,避免按钮和文案分别漂移。
* @param {'idle'|'streaming'|'done'|'cancelled'} state 当前请求状态。
*/
function setState(state) {
statusElement.dataset.state = state
statusElement.textContent = state.toUpperCase()
sendButton.disabled = state === 'streaming'
cancelButton.disabled = state !== 'streaming'
retryButton.disabled = state === 'streaming' || !lastQuestion
}
/** 终止仍在执行的本地流式计时器。 */
function clearStream() {
if (streamTimer !== null) {
window.clearInterval(streamTimer)
streamTimer = null
}
}
/**
* 按字符流式输出当前问题的答案。
* @param {string} question 用户提交的问题。
*/
function streamAnswer(question) {
clearStream()
/** 当前问题对应的离线答案。 */
const answer =
ANSWERS[question] || `生产环境会把“${question}”发送给后端;前端只负责请求状态、流式渲染、取消与重试。`
/** 当前已经输出的字符位置。 */
let cursor = 0
/** 当前请求开始时间。 */
const startedAt = performance.now()
answerElement.textContent = ''
lastQuestion = question
setState('streaming')
streamTimer = window.setInterval(() => {
cursor += 1
answerElement.textContent = answer.slice(0, cursor)
metaElement.textContent = `${cursor} chars · ${Math.round(performance.now() - startedAt)} ms`
if (cursor >= answer.length) {
clearStream()
setState('done')
}
}, 22)
}
sendButton.addEventListener('click', () => streamAnswer(questionInput.value.trim() || '未输入问题'))
cancelButton.addEventListener('click', () => {
clearStream()
setState('cancelled')
})
retryButton.addEventListener('click', () => streamAnswer(lastQuestion))
document.querySelectorAll('[data-question]').forEach((button) => {
button.addEventListener('click', () => {
questionInput.value = button.dataset.question
streamAnswer(button.dataset.question)
})
})
setState('idle')
</script>
</body>
</html>
server.py
学完自测
选择所有正确答案;提交后逐项核对判断依据。