Google ADK + LiteLLM 本地api流式输出踩坑记录:TaoToken 统一 Key 下的 RunConfig 与 StreamingMode 配置骨架

发布时间:2026/9/26 3:49:42
Google ADK + LiteLLM 本地api流式输出踩坑记录:TaoToken 统一 Key 下的 RunConfig 与 StreamingMode 配置骨架 1. 问题现场流式输出为什么变成了憋大招如果你正在用 Google ADK 搭 Agent模型层通过 LiteLLM 接本地或兼容 OpenAI 协议的 API前端用 SSE 做打字机效果结果用户发一句话后界面卡住五到十秒然后整段回复啪地一次性冒出来——恭喜你踩到了 ADK 流式输出最隐蔽的一个坑。这个现象特别有迷惑性。因为你在LiteLlm里明明写了streamTrue翻源码也能看到它内部确实调用了litellm.acompletion(..., streamTrue)模型层是真的在逐 chunk 收数据。可前端就是没有逐字效果。问题不在模型适配层而在更上层的 Runner 调度逻辑ADK 的Runner.run_async()默认使用StreamingMode.NONE它会把模型层收到的所有流式分片先攒成一个完整响应再一次性 yield 出来。模型在流Runner 在攒前端自然只能看到最终结果。这篇记录面向正在用 Google ADK LiteLLM 做本地 API 接入、并且被流式输出问题卡住的开发者。我会把RunConfig与StreamingMode的参数组合讲清楚给出一份可以直接复制的config.toml骨架再配合 TaoToken 统一 Key 的接入方式最后用一个最小验证请求确认流式分片真的在逐块返回。整套流程我自己跑通过坑也基本踩全了。2. 前置准备TaoToken 统一 Key 与 ADK 环境在动手改配置之前先把模型接入这一层理顺。很多流式问题其实混着两类原因一类是 ADK 的 Runner 配置另一类是 API 端点本身不支持流式或 Key 权限不对。用 TaoToken 统一 Key 的好处是你可以在一个 Key 下切换不同模型做对比测试快速判断问题到底出在框架层还是接入层。TaoToken 的 API 端点是https://taotoken.net/api兼容 OpenAI 协议所以 LiteLLM 可以直接把它当成一个 OpenAI 兼容后端来用。你需要在控制台创建一个 API Key然后把它写进环境变量避免硬编码进代码。export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_API_BASEhttps://taotoken.net/api安装依赖这块ADK 和 LiteLLM 都要装。ADK 的 Python 包名是google-adkLiteLLM 单独装一份方便调试pip install google-adk litellm fastapi uvicorn sse-starlette装完之后建议先单独验证一下 LiteLLM 能不能流式拿到分片这一步能把API 端点不支持流式这种可能性直接排除掉。写一个最小脚本import asyncio, os from litellm import acompletion async def main(): resp await acompletion( modelopenai/qwen3-max, api_baseos.environ[TAOTOKEN_API_BASE], api_keyos.environ[TAOTOKEN_API_KEY], messages[{role: user, content: 数到五}], streamTrue, ) async for chunk in resp: delta chunk.choices[0].delta.content if delta: print(delta, end, flushTrue) asyncio.run(main())如果这段能逐字打印说明模型层和 API 端点都没问题问题百分百在 ADK 的 Runner 配置上。如果这段也是一次性输出那要先检查端点是否真的支持 SSE或者 Key 是否被限流。这一步的排查价值很高别跳过。3. 可复制配置RunConfig 与 StreamingMode 骨架确认模型层能流之后回到 ADK。核心结论只有一句话必须在Runner.run_async()里显式传入RunConfig(streaming_modeStreamingMode.SSE)。LiteLlm(streamTrue)只管模型层收分片管不了 Runner 怎么往外吐事件。先看StreamingMode的三个取值理解它们才能配对模式值行为适用场景NONENone默认值攒完整响应再 yield非流式接口、批处理SSEsse逐 chunk yield 事件Web SSE、打字机效果BIDIbidi双向流式run_live实时语音/视频RunConfig是个 Pydantic 模型streaming_mode字段默认就是StreamingMode.NONE。这就是为什么很多人只在模型层设了streamTrue却毫无效果——Runner 根本没打算往外流。下面是我实际在用的config.toml骨架把模型、Runner、服务端口都收在一处方便切换环境[model] provider litellm model_name openai/qwen3-max api_base https://taotoken.net/api api_key_env TAOTOKEN_API_KEY stream true [runner] # 关键显式启用 SSE 流式默认 NONE 会导致攒批输出 streaming_mode sse # 单次会话最大事件数防止异常情况下无限流 max_events 200 [server] host 0.0.0.0 port 8000 sse_media_type text/event-stream读取这份配置并构造 Agent 与 Runner 的代码大致如下。注意RunConfig是在调用run_async时传的不是构造 Runner 时传的这个位置很容易搞错import os, tomllib from google.adk.agents import Agent from google.adk.models.lite_llm import LiteLlm from google.adk.runners import Runner from google.adk.agents.run_config import RunConfig, StreamingMode with open(config.toml, rb) as f: cfg tomllib.load(f) agent Agent( modelLiteLlm( modelcfg[model][model_name], api_basecfg[model][api_base], api_keyos.environ[cfg[model][api_key_env]], streamcfg[model][stream], ), namedoc_assistant, instruction你是一个简洁的智能助手回答控制在三句话内。, ) runner Runner(agentagent, app_namedemo) # 关键每次调用都带上 SSE 流式配置 run_config RunConfig(streaming_modeStreamingMode.SSE)到这里配置骨架就齐了。真正决定流式成败的就是StreamingMode.SSE这一行其余都是配套。4. 验证请求确认分片逐块返回配置写好后必须用一个最小请求验证流式分片真的在逐块返回而不是看起来流了其实还是一次性。我建议先用命令行直接打后端排除前端 JS 的干扰。后端用 FastAPI SSE 暴露一个接口核心是正确处理event.partial。启用StreamingMode.SSE后Runner 会 yield 两类事件partialTrue的逐 token 分片和partialFalse的聚合事件完整响应。如果两类都往外发前端文字会显示两遍这是第二个高频坑。from fastapi import FastAPI from fastapi.responses import StreamingResponse from google.genai import types app FastAPI() app.get(/chat) async def chat(q: str): async def gen(): user_msg types.Content(roleuser, parts[types.Part(textq)]) async for event in runner.run_async( user_iddefault, session_ids1, new_messageuser_msg, run_configrun_config, # ← 必须传否则不流式 ): # 只输出 partialTrue 的分片跳过聚合事件避免重复 if not event.partial: continue if event.content and event.content.parts: for part in event.content.parts: if part.text: yield fdata: {part.text}\n\n yield data: [DONE]\n\n return StreamingResponse(gen(), media_typetext/event-stream)启动服务后用 curl 验证重点看输出是不是挤牙膏式地一块块出来uvicorn main:app --port 8000 curl -N http://localhost:8000/chat?q用一句话介绍你自己-N参数关闭 curl 的缓冲能实时看到分片。正常结果应该是类似这样的逐块输出每块之间有明显时间间隔data: 我 data: 是 data: 一个 data: 智能 data: 助手 data: [DONE]如果 curl 这边能看到逐块但浏览器里还是一整段那问题就转移到前端了。前端最常见的错误是用替换而不是追加导致只显示最后一个 chunk。正确做法是维护一个累积变量let accumulatedText ; const es new EventSource(/chat?q你好); es.onmessage (e) { if (e.data [DONE]) { es.close(); return; } accumulatedText e.data; // 关键追加而非替换 document.getElementById(out).textContent accumulatedText; };另外如果 Agent 带工具调用工具返回后新一轮回复会接在旧文字后面看起来像文字混乱。解决办法是在检测到工具调用事件时把accumulatedText重置为空字符串。这个细节在纯文本对话里不会暴露一旦上工具就会翻车。5. 本篇常见错排查把上面几个坑集中列一下方便你对照自己的现象快速定位。这些基本都是我在调试过程中真实遇到过的。现象一响应一次性返回完全没有流式。根因是Runner.run_async()没传RunConfig默认StreamingMode.NONE在攒批。解决就是显式传RunConfig(streaming_modeStreamingMode.SSE)。注意LiteLlm(streamTrue)不能替代这一步。现象二文字显示两遍。根因是partialFalse的聚合事件也被输出了。解决是在循环里if not event.partial: continue只发分片。现象三前端只显示最后一个 chunk。根因是前端用赋值而非追加。解决是用累积变量。现象四工具调用后文字接在旧内容后面。根因是累积变量没重置。解决是在工具调用事件处清空累积变量。现象五curl 能流但浏览器不流。多半是中间层缓冲比如 Nginx 没关proxy_buffering或者响应头缺X-Accel-Buffering: no。SSE 场景下这两个设置很关键。现象六模型层报流式不支持。回到第 2 节的最小 LiteLLM 脚本单独验证确认 API 端点本身支持 SSE。如果端点不支持换支持流式的模型或端点即可。排查顺序建议从下往上先确认 API 端点能流LiteLLM 脚本再确认 Runner 配置对StreamingMode.SSE最后确认前端追加逻辑对。这样能避免在错误的层反复改代码。6. 接入与验证入口流式跑通之后如果你想把 TaoToken 统一 Key 正式接进项目建议先把 Key 管理起来再对照接入文档确认参数。API Key 在控制台创建接入细节看文档模型效果可以直接在对话页验证长期做编码或 Agent 的话可以了解下 Coding Plan。创建和管理 Keyhttps://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite接入文档含 OpenAI 兼容说明https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite模型对话验证https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewriteCoding Plan长期编码/Agenthttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite最后补一个实用技巧调试流式时把RunConfig的streaming_mode做成环境变量可切换这样同一套代码既能跑流式也能跑非流式对比排查会快很多。我试过在config.toml里加一个[runner] streaming_mode字段本地调试时改成none复现问题改成sse验证修复来回切几次就能把根因锁死。