
1. 本地 Ollama 对话网页为什么值得自己搭一套Ollama 装好之后命令行里ollama run就能聊天但每次都要开终端、复制粘贴体验很割裂。更实际的问题是如果你想把本地模型接进自己的工具链、给团队做个内网问答页、或者单纯想练手 SSE 流式推送命令行是满足不了的。这时候用 SpringBoot 搭一个网页后端把 Ollama 的流式输出通过 SSE 推到浏览器就是一条很顺的路径。这篇要解决的就是这条完整链路本地 Ollama 跑起来 → SpringBoot 提供 SSE 接口 → 前端网页逐字显示回复 → 同时留出 TaoToken 统一 Key 的配置骨架方便你以后把请求切到云端模型或做多模型路由。适合有 Java 基础、想跑通「网页访问本地大模型」的开发者也适合正在找 SSE 实战案例的人。我试过把 Ollama 的/api/generate直接暴露给前端结果跨域和流式解析两头卡最后还是回到 SpringBoot 中转的方案。下面按可复制的顺序来每一步都有命令和配置。2. 前置准备Ollama 运行与 TaoToken 通道骨架2.1 确认 Ollama 服务在跑Ollama 安装后默认监听11434端口。启动项目之前先确认服务活着ollama serve如果提示端口被占用说明后台已经在跑了直接验证模型列表ollama list拉一个轻量模型做测试1.5b 在普通笔记本上也能跑ollama run deepseek-r1:1.5b能进对话就说明本地推理链路通了。按CtrlD退出服务不会停。注意SpringBoot 项目启动前必须保证 Ollama 在运行否则 SSE 请求会直接报连接拒绝。2.2 TaoToken 统一 Key 的定位本地 Ollama 不需要 Key但一旦你想把同一套后端代码切到云端模型、或者做本地云端的混合路由就需要一个统一的 API 通道。TaoToken 在这里扮演的是「统一 Key 统一入口」的角色你拿一个 Key通过https://taotoken.net/api这个基地址去调不同模型后端代码不用为每个厂商写一套鉴权。配置骨架放在application.yml里用环境变量注入别硬编码taotoken: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY:} model: deepseek-r1:1.5b对应的settings.json示例如果你用支持该格式的客户端或工具链{ provider: taotoken, baseUrl: https://taotoken.net/api, apiKey: sk-你的Key, model: deepseek-r1:1.5b, stream: true }Key 的获取入口在控制台的 API Keys 页面建议单独建一个项目 Key方便按项目停用。接入文档里有各语言的最小请求示例排障时对照着看比猜快。3. 可复制配置SpringBoot SSE 后端三件套3.1 依赖与项目结构创建 SpringBoot 项目时勾选 Spring Web 和 WebFluxWebClient 在 WebFlux 里。目录结构src/main/java/com/example/ ├── config/WebConfig.java ├── controller/ChatController.java └── service/OllamaService.java src/main/resources/static/index.htmlpom.xml关键依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency3.2 跨域配置前端页面和后端同源时其实不需要但开发阶段用文件方式打开 HTML 会触发跨域先配上Configuration public class WebConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(*) .allowedMethods(*); } }3.3 SSE 控制器RestController RequestMapping(/api) public class ChatController { Autowired private OllamaService ollamaService; GetMapping(/chat-stream) public SseEmitter streamChat(RequestParam String message) { SseEmitter emitter new SseEmitter(0L); ollamaService.streamResponse(message, emitter); return emitter; } }new SseEmitter(0L)表示不设超时长回复不会被截断。默认超时是 30 秒大模型吐字慢的时候很容易踩这个坑。3.4 服务层把 Ollama 流式输出转成 SSEService public class OllamaService { private static final String OLLAMA_API_URL http://localhost:11434/api/generate; private final ObjectMapper objectMapper new ObjectMapper(); public void streamResponse(String message, SseEmitter emitter) { WebClient.create() .post() .uri(OLLAMA_API_URL) .contentType(MediaType.APPLICATION_JSON) .bodyValue(Map.of( model, deepseek-r1:1.5b, prompt, message, stream, true )) .retrieve() .bodyToFlux(String.class) .doOnComplete(() - sendCompletionSignal(emitter)) .subscribe( data - processData(data, emitter), error - handleError(emitter, error) ); } private void processData(String data, SseEmitter emitter) { try { JsonNode json objectMapper.readTree(data); String response json.get(response).asText(); emitter.send(SseEmitter.event().name(message).data(response)); } catch (Exception e) { handleError(emitter, new RuntimeException(数据解析失败: data, e)); } } private void sendCompletionSignal(SseEmitter emitter) { try { emitter.send(SseEmitter.event().name(done).data(COMPLETED)); emitter.complete(); } catch (IOException e) { emitter.completeWithError(e); } } private void handleError(SseEmitter emitter, Throwable error) { try { emitter.send(SseEmitter.event().name(error).data(服务请求失败: error.getMessage())); emitter.completeWithError(error); } catch (IOException e) { emitter.completeWithError(e); } } }这里的关键点Ollama 的流式响应是一行一个 JSON每个 JSON 里response字段是增量文本。bodyToFlux(String.class)按行拆开逐条解析后通过SseEmitter.event().name(message)推给前端。前端监听message事件累加文本监听done事件收尾。4. 验证请求curl 与网页双通道确认4.1 先用 curl 验证 Ollama 本身在启动 SpringBoot 之前直接打 Ollama 的接口确认流式返回正常curl http://localhost:11434/api/generate -d { model: deepseek-r1:1.5b, prompt: 用一句话解释什么是SSE, stream: true }你会看到一行行 JSON 往外冒每行都有response字段。如果这里卡住不动问题在 Ollama 侧不用往下查 SpringBoot。4.2 再验证 SpringBoot 的 SSE 接口项目启动后用 curl 打自己的接口curl -N http://localhost:8080/api/chat-stream?message你好-N关闭缓冲能实时看到event:message和data:...交替出现。看到event:done就说明整条链路通了。4.3 网页端确认index.html放在src/main/resources/static/下启动后直接访问http://localhost:8080/index.html。输入问题AI 回复应该逐字出现末尾有光标闪烁完成后光标消失。前端核心逻辑是EventSourceconst apiUrl http://localhost:8080/api/chat-stream?message${encodeURIComponent(message)}; currentEventSource new EventSource(apiUrl); currentEventSource.addEventListener(message, (event) { fullResponse event.data; updateAIReply(fullResponse); }); currentEventSource.addEventListener(done, () { finalizeAIReply(fullResponse); resetState(); });EventSource默认只支持 GET所以消息通过 query 参数传。消息长了 URL 会超长生产环境建议改成 POST fetch 的 ReadableStream 方案但作为跑通链路GET 足够。5. 本篇常见错排查5.1 连接被拒绝Connection refused报错Connection refused: localhost/127.0.0.1:11434说明 Ollama 没跑。执行ollama serve后重试。如果是远程访问把OLLAMA_API_URL里的localhost改成 Ollama 所在机器的 IP同时确认 Ollama 监听了外部地址。5.2 SSE 请求 30 秒后断开默认SseEmitter超时 30 秒长回复会被截断。改成new SseEmitter(0L)或设置一个足够大的值。另外 Nginx 反代时proxy_read_timeout也要调大否则网关层先断。5.3 前端收不到流式数据一次性全出来检查两处一是curl -N是否实时输出如果 curl 也是攒着一起出问题在后端缓冲二是 Nginx 是否开了proxy_buffering off。SpringBoot 侧确认返回的 Content-Type 是text/event-stream。5.4 中文乱码SseEmitter默认用 UTF-8但如果你手动设置了produces text/event-stream;charsetISO-8859-1就会乱。检查GetMapping有没有多余的 produces 声明去掉即可。5.5 模型名写错导致 404Ollama 返回model not found说明bodyValue里的model字段和ollama list里的名字不一致。注意带 tag 的完整名称比如deepseek-r1:1.5b不能简写成deepseek-r1。5.6 TaoToken Key 未生效如果切到 TaoToken 通道后返回 401先确认TAOTOKEN_API_KEY环境变量真的注入了可以在启动日志里打印一下长度别打印完整 Key。再对照接入文档检查请求头格式通常是Authorization: Bearer sk-xxx。排障阶段建议先用模型对话页面手动发一条确认 Key 本身可用再回到代码里查。6. 把链路固定下来下一步怎么走跑通之后建议把 Ollama 地址、模型名、TaoToken 的 base-url 和 Key 全部抽到application.yml用ConfigurationProperties绑定这样切换本地/云端只改配置不改代码。长期做编码辅助或 Agent 的话可以考虑 Coding Plan 那类按量方案把 Key 管理和额度控制交给平台自己专注在业务逻辑上。最后留一个实用技巧SSE 接口加一个GetMapping(/health)返回 Ollama 的连通状态前端页面加载时先探一下连不上就直接提示「本地模型未启动」比让用户对着转圈的光标猜要友好得多。