山东大学软件学院项目实训-创新实训-大数据租房推荐智能体-前端部分(2)
这篇博客主要记录一下我在项目第二阶段做的工作:如何把原本“一次性蹦出来”的回复改成“打字机”效果,以及如何优化代码结构,让前后端逻辑分离得更清楚。
1. 为什么要搞流式输出?
在上一阶段,虽然我用“双阶段刷新”解决了发送消息后的白屏问题,但用户体验其实还是不够好。现在的 AI 回复是等后端全部生成完才一次性显示出来,如果回答很长,用户得盯着屏幕干等好几秒。
真正的 AI 应用(比如 ChatGPT)都是“流式”的——字是一个个蹦出来的。这样做有两个好处:一是让用户感觉系统反应很快(首字延迟低);二是看着文字一点点生成,等待的焦虑感会少很多。
2. 代码结构的调整
为了实现流式,我不能再把模拟数据直接写在 app.py 里了。为了保持代码整洁,我把“怎么获取数据”和“怎么展示数据”分开了:
app_lib/streaming.py:专门负责“生产”数据。这里我写了一个生成器,用来模拟后端一点点吐出文字的过程。
app_lib/agent_client.py:这是一个中间层。它负责调用上面的生成器(未来这里会换成真实的 HTTP 请求),把数据拿回来。
app.py:只负责“展示”。它调用中间层拿到的数据流,然后用 Streamlit 的组件画出来。
通过这种分层,以后接入真实后端时,我只需要改 agent_client.py,完全不用动页面逻辑
3. 核心实现:模拟数据生成器
因为后端还没好,我得先自己造一个“假后端”。这里用到了 Python 的 yield 关键字,它能让函数变成一个“生成器”,每次只吐出一个字,然后暂停一下。
def _mock_reply_chunks(user_text: str) -> Iterator[str]:
//模拟回复
base = (
"已收到您的需求,当前为**前端模拟流式**输出(未配置 AGENT_STREAM_URL)。\n\n"
f"您提到:{user_text[:200]}{'…' if len(user_text) > 200 else ''}\n\n"
"配置环境变量或 `.streamlit/secrets.toml` 中的 `AGENT_STREAM_URL` 后,"
"将改为请求后端 SSE(`text/event-stream`,每行 `data:` 见 `sse_lines_to_text_chunks`)。"
)
step = max(1, MOCK_STREAM_CHUNK_CHARS)
for i in range(0, len(base), step):
yield base[i : i + step]
time.sleep(0.012)
在这个阶段,我没有简单地按“字”来切分文本,而是设置了一个 step 步长(比如每次 8 个字符)。这样做是因为真实的网络流(SSE)通常是一次传输一小段数据块,而不是一个字一个字地发。通过 yield base[i : i + step],我能更逼真地模拟出那种“数据一点点加载”的感觉。
4. 核心难点:Streamlit 的流式渲染
有了数据源,接下来就是怎么在页面上展示。Streamlit 提供了一个非常好用的组件叫 st.write_stream,它专门用来消费这种生成器数据。
相比起以前用 st.empty() 配合循环去不断更新文本,st.write_stream 更加丝滑。但我的 app.py 里不仅仅是简单的调用,我还处理了两个关键问题:
首字延迟优化(_prime_stream):为了不让用户看到白屏,我写了一个 _prime_stream 包装器。它会先在 st.spinner 里预加载第一个数据包,确保连接建立后再把控制权交给 st.write_stream,这样能最大程度减少“转圈”的时间。
异常兜底:流式传输最怕断连。我用了 try...except 包裹了整个流式过程。如果连接失败,我会捕获 StreamConnectionError,并把已经接收到的部分内容(acc)显示出来,而不是让页面直接报错崩溃。
def _render_chat() -> None:
st.title(f"{PAGE_ICON} {PAGE_TITLE}")
if prompt := st.chat_input("描述区域、预算、户型等找房需求…"):
append_message("user", prompt)
st.session_state.pending_reply = True
st.rerun()
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
if st.session_state.get("pending_reply"):
messages: list[dict[str, Any]] = st.session_state.messages
user_text = ""
if messages and messages[-1].get("role") == "user":
user_text = str(messages[-1].get("content", ""))
stream_url = resolve_stream_url()
history = messages[:-1] if messages else []
acc: list[str] = []
sid = new_streaming_id()
stream_error: str | None = None
def _chunk_iter() -> Iterator[str]:
for chunk in stream_assistant_reply(
user_text,
stream_url=stream_url,
history=history,
):
acc.append(chunk)
yield chunk
with st.chat_message("assistant"):
try:
st.write_stream(_prime_stream(lambda: _chunk_iter()))
except StreamConnectionError as e:
stream_error = str(e)
st.error(stream_error)
if acc:
st.caption("以下为已接收的部分内容:")
st.markdown("".join(acc))
except Exception as e: # noqa: BLE001 — 避免流中断时静默失败
stream_error = str(e)
st.error(f"流式输出异常:{e}")
if acc:
st.markdown("".join(acc))
full = "".join(acc)
if not full.strip():
full = (
f"(响应失败:{stream_error})"
if stream_error
else "(未收到有效回复,请检查后端或网络。)"
)
finished = time.time()
append_message(
"assistant",
full,
streaming_id=sid,
finished_at=finished,
)
st.session_state.pending_reply = False
st.rerun()
5. 总结与展望
现在的对话体验已经非常接近真实的 AI 产品了。字是一个个打出来的,而且因为我把数据获取逻辑封装在了 agent_client 里,整个 app.py 依然很干净。
接下来我要进入第三阶段:房源卡片与列表展示。
更多推荐
所有评论(0)