代码语言

知识点思维导图

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 态(禁用按钮、显示占位) syncUIsendMessage 里的 thinking
区分 HTTP 错误(4xx/5xx) sendMessageif (!res.ok)
区分网络层异常 sendMessagecatch
错误态 + 重试入口 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

学完自测

选择所有正确答案;提交后逐项核对判断依据。

1在“前端调用 AI 接口”中,需要同时满足“前端调用 AI 接口的真实应用场景”与“把一次调用拆成一组状态”。给定正文约束“调 AI 接口不是「发请求拿结果」这么简单。”,哪些判断保持了原有处理机制?多选
2“前端调用 AI 接口”出现偏差:“在“前端调用 AI 接口 / 三种失败,要分开处理”中,即使不满足“「失败」不是一种,是三种,处理方式不同”,结果与副作用仍会保持不变。”已成为实际行为。围绕“三种失败,要分开处理”与“给用户一个重试入口”,哪些判断能定位被改变的职责或边界?多选
3评审“前端调用 AI 接口”方案时,验收条件包含“前端调用 AI 接口要处理好哪些点? 我把它当一组 UI 状态而不是一次 fetch:idle、loading、error,成功只是其中一条路径。”。关于“一句话面试答法”与“动手实践:16 前端调用 AI 接口”的哪些决策符合正文机制?多选