当前位置: 首页 > news >正文

流式输出实战,让Agent边想边说提升用户体验

流式输出实战,让Agent边想边说提升用户体验

上一篇聊回调的时候提到过on_llm_new_token,说流式输出会用到它,这篇就接着往下聊。做Agent应用有个很现实的体验问题,用户点一下发送,然后盯着空白屏幕干等,长一点的回答要等十几秒,耐心差的直接以为程序卡死了。换成流式输出,模型一边生成一边往外吐字,前端打字机效果跑起来,用户至少知道活儿在干,等待感一下子弱很多。这篇就把流式输出从头到尾捋一遍。


为什么要流式输出

先把账算明白。模型生成长文本,整段返回和逐字返回,总耗时其实差不多,模型该算的token一个都没少。差别全在用户感知上。

人等东西有个心理阈值,大概两三秒。两秒内有反馈,觉得流畅。超过五秒没动静,就开始怀疑是不是崩了。整段返回就是让用户干等到最后那一刻才看见东西,前面全是空白焦虑。流式输出把等待切成一小段一小段,第一个token几百毫秒就到,后面持续有字往外蹦,大脑就知道活儿还在干,体感上快得多。

我做过一个客服Agent,回答动辄三四百字,整段返回平均八秒。用户投诉说卡,后台一看其实没卡,就是返回太慢用户以为卡了。换成流式,首字一秒内出来,投诉立马没了。流式输出解决的是感知问题,把干等变成看得见的进度。


LangChain的stream方法

LangChain里所有Runnable都带stream方法,上一篇讲LCEL的时候提过一句。调起来很简单,把invoke换成stream,返回的是一个迭代器,逐个吐出输出块。

fromlangchain_openaiimportChatOpenAIfromlangchain_core.promptsimportChatPromptTemplatefromlangchain_core.output_parsersimportStrOutputParser model=ChatOpenAI(model="gpt-4o-mini")prompt=ChatPromptTemplate.from_template("解释{concept}")chain=prompt|model|StrOutputParser()forchunkinchain.stream({"concept":"向量检索"}):print(chunk,end="",flush=True)

跑起来你会看到字一个一个往外冒,这就是流式效果。chunk是模型每次吐出来的一小段文本,可能一个字,可能几个字,取决于模型。flush=True很关键,不加的话终端会攒一批才刷新,流式的感觉就没了。

这里有个坑我踩过。流式输出时StrOutputParser吐的是字符串片段,但如果你不接解析器,直接遍历模型,拿到的是AIMessageChunk对象,得取它的content属性才是文本。新手第一次跑流式,看到屏幕上一堆对象repr打印出来,就是这个原因。


astream异步流式

同步stream在脚本里够用,放到Web框架里就会卡住事件循环。FastAPI这类异步框架里得用astream,异步版本的流式。

importasynciofromlangchain_openaiimportChatOpenAIfromlangchain_core.promptsimportChatPromptTemplatefromlangchain_core.output_parsersimportStrOutputParser model=ChatOpenAI(model="gpt-4o-mini")prompt=ChatPromptTemplate.from_template("解释{concept}")chain=prompt|model|StrOutputParser()asyncdefmain():asyncforchunkinchain.astream({"concept":"向量检索"}):print(chunk,end="",flush=True)asyncio.run(main())

async for遍历异步迭代器,每个chunk到了就处理,不阻塞。凡是跑在异步框架里的流式,都得用astream,用同步stream会把整个事件循环堵死,别的请求全排队等它。


token级别流式

上面的stream拿到的是链最终输出的片段。有时候你想更细,拿到模型原始的token流,比如想在前端给思考过程和最终回答分开渲染。这时候得直接在模型上调stream,或者用回调里的on_llm_new_token。

直接在模型上流式最直接。

model=ChatOpenAI(model="gpt-4o-mini",streaming=True)forchunkinmodel.stream("写一首关于秋天的诗"):print(chunk.content,end="",flush=True)

注意这里取的是chunk.content,因为模型直接流式返回的是AIMessageChunk。LangChain新版本里streaming参数可以不显式传,调stream方法会自动开启流式,但写上更清楚,免得后面看代码的人犯嘀咕。

上一篇回调里埋的on_llm_new_token,这里也能接上。把一个回调处理器塞进去,每个token生成时触发一次,想在token级别做点统计或者日志,都走这个口子。

还有个更现代的口子叫astream_events。链里混了工具调用、检索这些步骤时,它能区分每个步骤的事件,工具开始、工具结束、模型生成token,分得清清楚楚。做Agent流式时这个特别有用,你想把工具调用过程也流式展示给用户看,靠astream_events最省心。


结合FastAPI做SSE流式接口

光在终端里流式没意思,真正要落地得做个接口给前端调。Web上做流式推送的标准做法是SSE,服务器发送事件。FastAPI原生支持StreamingResponse,配合astream很顺手。

fromfastapiimportFastAPIfromfastapi.responsesimportStreamingResponsefromlangchain_openaiimportChatOpenAIfromlangchain_core.promptsimportChatPromptTemplatefromlangchain_core.output_parsersimportStrOutputParser app=FastAPI()model=ChatOpenAI(model="gpt-4o-mini")prompt=ChatPromptTemplate.from_template("回答问题{question}")chain=prompt|model|StrOutputParser()@app.get("/chat")defchat(question:str):asyncdefevent_stream():asyncforchunkinchain.astream({"question":question}):yieldf"data:{chunk}\n\n"returnStreamingResponse(event_stream(),media_type="text/event-stream")

SSE的格式有讲究,每条消息以data打头,结尾两个换行。前端用EventSource接,浏览器自动按这个格式解析。返回类型设成text/event-stream,浏览器才知道这是SSE流。

这里有个细节容易漏。chain.astream是异步的,yield拼字符串是同步操作,混在async函数里没问题。但如果你在流式过程中还要查数据库,记得用异步查询方法,别在async函数里调同步阻塞IO,不然流式效果会一卡一卡地出字。

还有一点,流式连接断了得处理。用户关掉页面或者网络抖动,前端EventSource会触发onerror,后端这边异步生成器会收到取消信号。记得在event_stream里用try finally清理资源,别留下半截没跑完的状态。


前端展示方案

后端接口有了,前端怎么接。SSE用浏览器原生的EventSource最省事。

constsource=newEventSource("/chat?question=什么是Agent");source.onmessage=(event)=>{document.getElementById("output").textContent+=event.data;};source.onerror=()=>{source.close();};

每收到一个chunk拼到页面上,打字机效果就有了。EventSource的坑在于只支持GET请求,没法带请求体。要传大段参数或者走POST,得用fetch配合ReadableStream手动解析,或者上第三方库。简单场景EventSource够用,复杂场景再换fetch。

还有一个体验细节,前端最好加个光标动画。字往外蹦的时候末尾跟个闪烁光标,用户一眼就知道还在生成,生成完了把光标去掉。这种小细节对体感提升挺明显,花不了几行代码。


小结

这篇把流式输出从原理到落地过了一遍。为什么流式解决的是用户感知问题,LangChain的stream同步流式,astream异步流式,token级别的细粒度流式,FastAPI的SSE接口,前端EventSource展示。串起来就是一个完整方案,Agent从干等结果变成边想边说,体验上了一个台阶。

不过你会发现astream用了async,FastAPI也全是async。异步这东西在Agent里到处都是,但很多人用着用着就踩坑,同步异步混着调,事件循环堵死,并发没提起来反而更慢。下一篇就聊异步与并发,把asyncio在Agent里的门道理清楚。

http://www.jsqmd.com/news/1346287/

相关文章:

  • UE5与Web双向通讯实战:基于WebUI插件实现游戏内嵌交互界面
  • 怎么让文心生成 excel:AI 导出鸭一站式搞定表格导出,多类转换方法横向对比解析
  • 如何用Montserrat免费字体打造专业设计:完整指南帮你节省数千元字体费用
  • 中药材分拣工岗位标准 + 中药材智能分拣产线Qt信创工控系统
  • WebMCP:让AI成为浏览器的“手”,开启AI原生Web应用新时代
  • LayerDivider:5分钟将单张图片智能转换为PSD分层文件的AI工具
  • Blender UV网格重塑终极指南:UvSquares插件快速入门与实战应用
  • UE5 RPG动态伤害公式:基于GAS Execution Calculations实现数据驱动计算
  • Visual Studio C++ DLL加载错误排查:从原理到实战解决“找不到”与“无法定位”
  • 【2026年百度暑期实习/秋招- 8月6日-算法岗-第一题- 波动贡献最大化】(题目+思路+JavaC++Python解析+在线测试)
  • 2026年重庆可定制钢丝骨架复合管挑选攻略:渝腾电力厂商信息及行业要点整理 - 董不懂啊
  • 3大常见问题解决方案:Sollumz Blender插件完全指南
  • 键键通手表保护膜:智能手表贴合防护方案 - 城刊速递
  • Windows防撤回终极指南:RevokeMsgPatcher让你的聊天记录永不消失
  • 终极Boot Camp驱动自动化工具:3分钟搞定Mac Windows驱动安装
  • 一键解放你的Steam游戏:SteamAutoCrack终极指南
  • 瑞兹电子解读CAT7网线双屏蔽技术与产业适配路径 - 城刊速递
  • WinCC集成Excel报表自动化方案与实现
  • Perseus项目:3步解锁碧蓝航线全皮肤功能的原生库补丁指南
  • 可白嫖源码---课程设计--毕业设计--springboot学生心理素质管理APP[编号:project20702](案例分析)
  • 有机硅电子灌封胶核心优势解析:性能、横向对比与应用领域 - 城刊速递
  • 上海涉税犯罪辩护律师怎么选?企业涉税刑事风险应对完整指南 - 思溯深度专栏
  • 深度学习实战-基于Resnet50的青光眼疾病图像识别模型
  • 终极指南:用Midscene实现跨平台AI自动化,3分钟告别重复劳动
  • Python零基础到接单实战:高效学习路径与避坑指南
  • 轨道模拟科普
  • 数字媒体内容包技术处理指南:FFmpeg与批量脚本实战
  • mxGraph图形库入门:核心架构、交互实现与Vue集成实战
  • PL2303驱动终极修复指南:如何在Windows 10上拯救你的老设备
  • 开海鲜自助餐厅5个设计问题 - 全域品牌推荐