React全栈实战:用DeepSeek API构建AI写作平台
2026/9/19 11:37:36 网站建设 项目流程

简介:面向有编程基础、希望快速上手全栈AI应用开发的读者,这份PDF系统讲解了如何结合DeepSeek生成API与React从零构建一个可运行的AI写作平台。文档由浅入深,先介绍DeepSeek的概念与API申请流程,再逐一说明prompt、max_tokens、temperature、top_p等生成参数的使用要点;随后过渡到React前端环境搭建、函数组件与状态管理,以及Flask后端集成DeepSeek接口、错误处理与性能优化;最后补充前后端跨域通信、Nginx部署、域名与SSL配置等上线实操内容,形成完整开发闭环。压缩包共1个PDF文件,大小2.04MB,内容完整,附有清晰目录,便于按章节查阅。目前已有96人学习,适合希望掌握生成式API工程化落地、并想从零完成一个全栈项目的开发者作为参考手册。

1. AI 写作平台真正要写的代码是什么:React 与 DeepSeek API 的边界

你打开任何一个 AI 写作编辑器,输入主题后文章逐字出现,这不是前端动画,而是后端把大模型生成的增量内容实时推给了界面。拆开看就是三条链路:DeepSeek 生成 API 提供文本能力,React 负责输入和流式内容渲染,中间一层服务端负责保管密钥、转发请求、控制参数。把这套链路从零搭起来,就是接下来所有章节要做的事。整个项目不涉及模型训练、不需要高配置服务器,核心投入在 API 调用参数、流式接收和全栈联调上。适合前端转全栈后想完整跑通一个 AI 应用的人,也适合要交付写作类工具的前端工程师。如果你正把它排进自己的 AI 全栈学习路线,这个项目的难度阶梯刚好卡在“能调通接口”和“能做出产品”之间。

2. DeepSeek 生成 API 的调用方式:鉴权、参数与 400 报错排查

2.1 生成 API 为什么是 OpenAI 兼容格式

DeepSeek 的 HTTP 接口遵循 OpenAI 的 Chat Completions 约定,请求体里放一个 messages 数组,数组元素带 role 和 content,服务端通过一次或多次返回生成内容。采用这种协议意味着,市面上大量基于 OpenAI 接口写的调用代码、SDK 封装和错误排查思路,都可以直接平移到 DeepSeek 上,理解成本被压到最低。

调用前需要去开放平台创建 API Key,密钥以 sk- 开头,且只在创建时完整展示一次,遗失就必须重新生成。调用入口是 POST https://api.deepseek.com/chat/completions,Node 18 以上自带全局 fetch,不需要为了调 HTTP 额外安装请求库。鉴权通过请求头完成:

Authorization: Bearer sk-xxxx

注意这个头的三个细节:必须带 Bearer 前缀,且前缀后有一个空格;Key 里不能混入换行符,否则服务端按整个字符串匹配就失败;不要把 Key 放到 URL 查询参数里,代理层和访问日志会把它明文记下来。

2.2 用 curl 验证最小请求

写业务代码前先用 curl 验证连通性,把网络问题和鉴权问题从代码问题里剥离出来。下面是最小可用的请求体:

curl https://api.deepseek.com/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $DEEPSEEK_API_KEY" \ -d '{ "model": "deepseek-chat", "messages": [ {"role": "system", "content": "你是一个中文技术写作助手。"}, {"role": "user", "content": "用三句话介绍 React 的虚拟 DOM。"} ], "temperature": 0.7, "max_tokens": 500 }'

一次成功的响应会在 choices[0].message.content 里返回完整正文。如果返回 401,先检查 Authorization 头是否带了 Bearer 前缀,以及环境变量里有没有残留空格。如果返回 400,最常见的两类原因都在请求体里:model 字段名称与你账号开通的模型不匹配,或 messages 最后一条不是 user 角色。许多网关的报错文本会直接列出它支持的模型名,形式类似 the supported api model names are ...,把你控制台里开通的那个名字填进 model,问题就解决了。不要在任何前端代码里硬编码模型名,后面会讲为什么。

2.3 请求参数与响应字段说明

请求体里最值得关注的字段如下表,写作平台场景下每个参数都有明确调优方向:

参数类型写作场景建议
modelstring填写控制台开通的模型名,区分 chat 和推理模型
messagesarraysystem 设角色和文风,user 放选题,历史消息按顺序放回
temperaturenumber0.7 适合说明文,大纲生成用 0.4,小说发散用 1.0
max_tokensint控制单次生成长度,估算时预留 system 和输入占用的 token
streamboolean写作编辑器固定 true,非流式只能拿到整段结果

响应体里的字段则各有用途:id 对应一次请求的唯一轨迹,排错时拿它去平台查费用和日志;choices[0].message.content 是核心生成文本;choices[0].finish_reason 为 stop 表示正常结束,length 表示内容被 max_tokens 截断;usage.prompt_tokens 与 completion_tokens 拆分计算输入和输出成本,做配额监控时这两个字段是唯一数据源。

2.4 接入 SDK 还是直接用 fetch

对只有一个补全接口的写作平台,直接用 fetch 就够了。SDK 的价值在重试机制、自动限流和更方便的流式处理,但这些在单实例服务里都可以用几十行代码实现。多数生产项目两种写法共存:网关层用 SDK 做统一封装,业务层用 fetch 保证行为透明,排错时能直接看到请求和响应。本文统一使用 fetch,原因只有一个:它的每一步都能在 Network 面板里复现,这是初学者把链路跑通的最短路径。

3. 用 Node.js 写全栈后端:把 DeepSeek 密钥留在服务端

3.1 前端直连 API 的两个硬伤

浏览器直连 DeepSeek 接口,把 sk- 密钥写进 React 代码里,任何一个打开 DevTools 的人都能把它拷走。个人学习项目也许无伤大雅,一旦部署成多人使用的平台,泄露的密钥就会被别人拿去刷你的余额。任何正规的全栈方案里,生成 API 都只允许从服务端访问,密钥永不进入浏览器。

CORS 是第二个问题。DeepSeek 接口默认不会对浏览器开放跨域许可,虽然可以去平台申请加白名单域名,但更干净的做法是让同域的后端代发请求,从根上把 CORS 绕开。两种方案的成本差异很大:白名单需要每周维护域名列表,而代理层几乎零成本,还能在里面加鉴权、限流和日志记录。

3.2 初始化服务端项目

使用 Express 搭一个最小服务端,Node.js 版本要求 18 以上:

mkdir ai-writer-server cd ai-writer-server npm init -y npm install express dotenv touch index.js

根目录下创建 .env 文件存放密钥,并确认它已写进 .gitignore:

DEEPSEEK_API_KEY=sk-xxxx DEEPSEEK_MODEL=deepseek-chat PORT=3001

入口文件 index.js 负责加载环境变量、注册中间件和启动服务:

import express from 'express'; import dotenv from 'dotenv'; import { generate } from './routes/generate.js'; dotenv.config(); const app = express(); app.use(express.json()); app.use('/api', generate); app.listen(process.env.PORT || 3001, () => { console.log(`server run at http://localhost:${process.env.PORT || 3001}`); });

这里的 dotenv.config() 必须在读 process.env 之前执行,否则 DEEPSEEK_API_KEY 会是 undefined;express.json() 用来解析前端发来的 JSON 请求体,缺少它会一直拿不到 prompt 字段。

3.3 转发请求到 DeepSeek 的最小实现

routes/generate.js 里写一个 POST 路由,把前端传进来的 prompt 和 system 拼入消息数组,再转发给 DeepSeek:

import { Router } from 'express'; const router = Router(); router.post('/generate', async (req, res) => { const { prompt, system = '你是一个中文写作助手。', temperature = 0.7, max_tokens = 2048, } = req.body; const body = { model: process.env.DEEPSEEK_MODEL, messages: [ { role: 'system', content: system }, { role: 'user', content: prompt }, ], temperature, max_tokens, stream: true, }; const upstream = await fetch('https://api.deepseek.com/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.DEEPSEEK_API_KEY}`, }, body: JSON.stringify(body), }); if (!upstream.ok) { const errText = await upstream.text(); res.status(upstream.status).json({ error: errText }); return; } res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); upstream.body.pipe(res); }); export { router as generate };

这段代码里最关键的是三处。第一,密钥只从环境变量读取,前端代码和网络请求面里都不存在明文。第二,stream 设为 true,上游响应会持续返回增量内容,再通过 upstream.body.pipe(res) 把 Node 的流直接接到 Express 响应上,省去手动解析 SSE 的工作。第三,响应头显式声明 text/event-stream,浏览器和代理层才会把它当作长连接处理,而不是等待完整响应体。

3.4 流式与非流式的取舍

如果去掉 stream 字段,接口会等待全文生成完毕才返回,前端表现为转圈几秒后整段出现;流式模式则让第一个字在几百毫秒内到达,后续内容逐段追加。写作平台的逐字输出效果必须靠 stream: true 实现,代价是前端处理逻辑复杂一层,需要自己拼接持续到达的文本,并在连接意外断开时保留已生成的部分。

另外要关注超时。Node 对上游请求默认没有超时限制,长文本生成可能持续几十秒,连接一旦挂起会长期占用服务端 socket。下面用 AbortController 把超时控制在 120 秒:

const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 120000); const upstream = await fetch(apiUrl, { method: 'POST', headers: headers, body: JSON.stringify(body), signal: controller.signal, }); clearTimeout(timer);

3.5 错误状态码与排查方向

把上游错误原样抛给前端不便于定位,这里整理一份常见状态码映射,服务端可以把错误码翻译成前端可读的消息:

状态码返回体关键词排查方向
400model 名称不支持或 messages 不合法核对控制台模型名与消息角色顺序
401Invalid API Key or token密钥失效、缺 Bearer 前缀
402Insufficient Balance账户欠费,生成接口被停用
429Rate limit reached并发过高,需要加退避重试
5xxInternal/Server error上游故障,间隔重试 2 到 3 次

4. React 前端消费 DeepSeek 流式接口:从 fetch 到编辑器

4.1 技术选型:React 与 Vue 的取舍

Vue 的模板语法上手快,React 的函数式写法在处理持续变化的状态时更直接:流式文本就是一个字符串,视图层根据状态重渲染,不需要与模板指令做额外同步。对准备前端转全栈的人来说,React 的生态覆盖面和岗位需求量更大,这也是本方案选它做主界面的原因。

项目骨架用 Vite 初始化,保留 JavaScript 模板,把示例代码清空后按职责拆分组件:

组件名文件路径职责
Writersrc/components/Writer.jsx输入框、生成按钮、加载状态
OutputPanelsrc/components/OutputPanel.jsx展示生成结果并允许修改
SettingsBarsrc/components/SettingsBar.jsxtemperature 与 max_tokens 滑块
Appsrc/App.jsx状态集中管理并调用 generate

这种划分下,App 是唯一发起请求的地方,子组件只负责把用户输入向上传,再把生成结果向下渲染。

4.2 第一次接流式接口:直接拼接文本

前端用 fetch 调用后端转发接口,把响应体当作可读流逐块读取:

const generate = async (prompt) => { setLoading(true); setOutput(''); try { const res = await fetch('/api/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt }), }); if (!res.ok) { const err = await res.json(); setError(err.error || `HTTP ${res.status}`); return; } const reader = res.body.getReader(); const decoder = new TextDecoder('utf-8'); let text = ''; while (true) { const { done, value } = await reader.read(); if (done) break; text += decoder.decode(value, { stream: true }); setOutput(text); } } finally { setLoading(false); } };

这套写法没有解析 SSE 里的 data: 前缀,而是把二进制流直接解码成字符串后追加。原因很实际:流式字节可能在任何位置断开,半行 JSON 无法直接 JSON.parse,与其引入状态管理处理碎片,不如先让文本稳定滚出来。String 在 JavaScript 拼接性能足够高,中文按字符追加,几千字的内容并不会卡顿。

4.3 升级为正规 SSE 解析

当后续需求增加 token 计数时,再升级为按行解析的版本。SSE 的标准格式是每个事件由 data: 开头,事件之间用空行分隔,流结束时发送 data: [DONE]:

let buffer = ''; let text = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith('data:')) continue; const payload = trimmed.slice(5).trim(); if (payload === '[DONE]') return; try { const json = JSON.parse(payload); const delta = json.choices?.[0]?.delta?.content; if (delta) { text += delta; setOutput(text); } } catch (e) { console.warn('sse parse skip', e); } } }

buffer 变量保留了不完整的行,避免数据被 TCP 分包切断时丢内容;JSON.parse 只处理完成的行,消费接口返回的增量字段。需要记录 token 用量时,也可以在每个 data 事件里累加 usage.completion_tokens。

4.4 把生成结果变成可编辑文本

生成之后要让用户能改文章。OutputPanel 里用一个受控 textarea 展示 output,用户既能手动修正,也可以选中内容后再次让模型续写。受控组件意味着所有修改都先进入 state,再回流到界面,后续“保存”“导出”功能可以直接基于这份 state 实现。

const OutputPanel = ({ value, onChange }) => ( <textarea value={value} onChange={(e) => onChange(e.target.value)} rows={24} className="output-panel" /> );

加载状态做成独立按钮文案,生成期间禁用提交按钮,避免用户连点触发多次计费请求。生成结束后把 finish_reason 为 length 的情况翻译成“内容已达到长度上限,请调大 max_tokens 或分段生成”,提示文案有实际业务含义,不是简单透传状态码。

5. 全栈联调清单与 3 个必调参数

5.1 本地联调命令与代理设置

分别启动服务端与前端两个进程:

cd ai-writer-server && npm run dev cd ai-writer-web && npm run dev

Vite 默认端口 5173,服务端在 3001,跨域问题交给 Vite 代理,在 vite.config.js 里配置:

export default defineConfig({ server: { proxy: { '/api': 'http://localhost:3001', }, }, });

打开浏览器 F12 的 Network 面板,请求链路是 React 发出 POST /api/generate,Vite 转发到 3001,服务端再向上游发起请求。如果一直看不到流式响应,先确认服务端响应头 Content-Type 是不是 text/event-stream,部分代理或压缩插件会吞掉它。

5.2 生产部署的 3 个必调参数

参数位置推荐设置作用
model 名称服务端环境变量注入,前端不出现以控制台开通列表为准避免前端传参被篡改导致 400 报错
请求超时服务端 fetch 的 AbortController120000 ms长文本生成超过 2 分钟主动断开
SSE 缓冲Nginx 反代配置proxy_buffering off让增量数据实时转发到浏览器

第三项在本地开发感知不到,部署在 Nginx 后会出现“等半天突然整段出现”的现象,原因是 Nginx 默认缓冲了响应。对应配置如下:

location /api/ { proxy_pass http://127.0.0.1:3001; proxy_buffering off; proxy_read_timeout 120s; }

5.3 验证流式生效的一个小技巧

联调时有个快速判断流式是否真正工作的办法:在服务端日志里观察请求耗时。如果整个请求在 10 到 30 秒后才返回,说明流被中间层缓存;如果请求发出后立即有内容持续秒级抵达,说明链路是通畅的。也可以用 curl 直接验证后端接口:

curl -N http://localhost:3001/api/generate \ -H "Content-Type: application/json" \ -d '{"prompt":"写一段关于 React 的简介"}'

-N 参数禁用 curl 的输出缓冲,看到逐行冒出的文字,代表服务端流式转发正常;这时再回到前端排查 React 侧代码即可,不用把问题发散到整个链路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询