Day 5 结束时,你的助手已经"真能用"了 —— 但只有你一个人能用:
source venv/bin/activate → python agent.py 才能开始聊。给别人看要先讲一遍装环境。ls。cp xxx.md notes/ → python ingest.py → 重启 Agent。KnowledgeAgent 类和 tools/memory 一行代码不改,只把它包在 Streamlit 里。agent.py 里到处是 print(),那 Day 6 第一件事就是把它们抠掉。
翻开 Day 5 的 agent.py 主循环,会看到这样的代码:
while True:
user_input = input("你: ")
...
print(f" ─ 第 {round_num} 轮 ─")
print(f" 🔧 {name}({args})")
print(f" ↳ {result[:200]}")
...
print(f"🤖 {answer}")
print(f"📊 本次任务:{rounds} 轮 | {tool_calls} 次工具调用 | {tokens} tokens")
问题很明显:调用 LLM、执行工具、追加 memory —— 这些"业务动作",和 input() / print() —— 这些"和终端说话的动作",全都塞在同一个函数里。
Streamlit 用不了 input()(它是 st.chat_input()),也用不了 print()(它是 st.markdown())。如果不重构,就得把整个循环复制一份、把每行 print 改成 st.xxx —— 逻辑马上就分叉了。
Day 6 的做法:把 Agent 抽成一个类,类里不做任何 IO。
class KnowledgeAgent:
def __init__(self, ...):
self.client = OpenAI(...)
self.memory = ConversationMemory(...)
self.tool_ctx = ToolContext(...)
def run_turn(self, user_input: str) -> TurnResult:
"""跑一次用户提问,返回结构化结果。不 print、不 input。"""
self.memory.append({"role": "user", "content": user_input})
rounds = []
for round_num in range(1, self.max_rounds + 1):
response = self.client.chat.completions.create(...)
round_trace = RoundTrace(round_num=round_num)
# ... 记录 tool_calls / tokens 到 round_trace
if not msg.tool_calls:
return TurnResult(answer=..., rounds=rounds, compression_event=...)
...
def clear_memory(self):
self.memory.clear()
def memory_stats(self) -> dict:
return {"n_messages": ..., "chars": ..., "trigger_chars": ...}
print、input、sys.stdout 就是没拆干净。TurnResult。CLI 或 Streamlit 拿着这个结果,各自决定怎么打印/渲染。
Day 5(CLI-only):
input() ─→ agent 主循环 ─→ print()
↑ 逻辑和 IO 纠缠 ↑
Day 6(引擎/表现分离):
┌── CLI 模式(agent.py main) ─→ print()
KnowledgeAgent ─┤
.run_turn() ─→ └── UI 模式(app.py) ─→ st.markdown()
返回 TurnResult
↑ 引擎只吐结构化数据,谁渲染无所谓 ↑
关键在于 run_turn() 返回什么。它不能只返回一个字符串(那样 UI 就没法展示"Agent 调用了什么工具、每一步的返回是什么")。
Day 6 定义了三层结构化 dataclass:
@dataclass
class ToolCallTrace:
"""一次工具调用的完整记录"""
name: str
arguments: str # JSON 字符串
result: str # 工具返回值
@dataclass
class RoundTrace:
"""一轮 LLM 调用的完整记录"""
round_num: int
tool_calls: list[ToolCallTrace] = field(default_factory=list)
assistant_text: str = "" # 若这轮 LLM 给出最终文本
prompt_tokens: int = 0
completion_tokens: int = 0
@dataclass
class TurnResult:
"""一次用户提问的完整结果(可能跨多轮 LLM)"""
answer: str
rounds: list[RoundTrace]
compression_event: dict | None = None # 本轮结束触发的压缩事件
@property
def total_tool_calls(self) -> int:
return sum(len(r.tool_calls) for r in self.rounds)
@property
def total_tokens(self) -> int:
return sum(r.prompt_tokens + r.completion_tokens for r in self.rounds)
好处:
render_turn_trace() 就是把它渲染成"第 N 轮 · 调用工具 xxx"的可折叠卡片)。Streamlit 的心智模型很简单:整个 Python 文件就是一次"渲染函数"。用户每交互一次,整个文件从头跑一遍。你写代码的顺序 = 页面从上到下的展示顺序。
st.set_page_config(page_title="知识助手 v3", layout="wide")
# 1. 一次性初始化 session state(KnowledgeAgent、history、token 计数)
_init_state()
# 2. 侧栏:知识库状态 / 上传 / 记忆
with st.sidebar:
st.title("📚 知识库")
...
# 3. 主区:标题 + 历史消息 + 输入框
st.title("🤖 知识助手 v3")
for msg in st.session_state.history:
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
if msg.get("turn"):
render_turn_trace(msg["turn"])
# 4. 用户输入 → 调 agent.run_turn() → 追加到 history → st.rerun()
user_input = st.chat_input("问问你的笔记 …")
if user_input:
...
因为整个文件每次都从头跑,普通 Python 变量每次都会被重置。想让 KnowledgeAgent 记住之前对话的 memory,就必须放到 st.session_state 里:
def _init_state():
if "agent" not in st.session_state:
st.session_state.agent = KnowledgeAgent() # 只在第一次跑时创建
if "history" not in st.session_state:
st.session_state.history = [] # 消息列表,rerun 保留
if "session_tokens" not in st.session_state:
st.session_state.session_tokens = 0
if "ingest_notice" not in st.session_state:
st.session_state.ingest_notice = None # 一次性提示
st.session_state.agent = KnowledgeAgent() 而没有 if not in 判断,那每次 rerun 都会新建一个 Agent —— 之前所有对话记忆全没了。判断存在性是必须的。
kb: KnowledgeBase = init_kb()
chunk_count = kb.collection.count()
notes = kb.list_notes()
if chunk_count == 0:
st.warning("知识库为空,请上传笔记后点击「重建索引」")
else:
st.success(f"{len(notes)} 篇笔记 · {chunk_count} 个 chunk")
with st.expander(f"📄 笔记列表({len(notes)})"):
for name in notes:
meta = kb.read_meta(name) # ← 复用 Day 5 的 frontmatter 解析
st.markdown(f"**{name}**")
if meta.get("tags"):
st.caption("🏷️ " + " · ".join(f"`{t}`" for t in meta["tags"]))
if meta.get("summary"):
st.caption(f"📝 {meta['summary']}")
注意这里的美妙之处:Day 5 存的 tags / summary 现在 UI 里一目了然。你上一天的努力立刻有了"人肉可读"的产出。
uploaded_files = st.file_uploader(
"拖入 .md 文件",
type=["md", "markdown"],
accept_multiple_files=True,
)
if st.button("💾 保存到 notes/", disabled=not uploaded_files):
for uf in uploaded_files:
(Path(NOTES_DIR) / uf.name).write_bytes(uf.getbuffer())
st.session_state.ingest_notice = f"✅ 已保存 {len(uploaded_files)} 个文件"
st.rerun()
if st.button("🔄 重建索引", type="primary"):
with st.spinner("正在建库(切分 + embedding)…"):
n_files, n_chunks = kb.rebuild()
st.session_state.ingest_notice = f"✅ {n_files} 文件 → {n_chunks} chunk"
st.rerun()
上传和建库分成两步,是刻意的设计 —— 建库要发 embedding 请求,有网络耗时。用户可能想一次性传 5 个文件再一起建。
stats = st.session_state.agent.memory_stats()
st.caption(f"{stats['n_messages']} 条消息 · {stats['chars']} 字符 / {stats['trigger_chars']} 触发压缩")
ratio = min(stats["chars"] / max(stats["trigger_chars"], 1), 1.0)
st.progress(ratio)
这是 Day 5 记忆系统的可视化。之前只有到达阈值才在 CLI 看到"🧠 [记忆压缩]"事件;现在你能实时看到"离下一次压缩还有多远",非常直观。
def render_turn_trace(turn: TurnResult):
"""把 TurnResult 里的工具调用轨迹渲染成可展开的 st.status 卡片"""
for r in turn.rounds:
if not r.tool_calls:
continue
tool_names = ", ".join(tc.name for tc in r.tool_calls)
with st.status(
f"第 {r.round_num} 轮 · 调用工具:{tool_names}",
state="complete",
expanded=False,
):
for tc in r.tool_calls:
st.markdown(f"**🔧 `{tc.name}`**")
st.code(tc.arguments or "{}", language="json")
st.markdown("**返回:**")
st.code(tc.result, language="text")
# 底部统计
st.caption(f"📊 {len(turn.rounds)} 轮 LLM · {turn.total_tool_calls} 次工具调用 · {turn.total_tokens} tokens")
if turn.compression_event:
e = turn.compression_event
st.info(
f"🧠 **记忆压缩**:{e['before_msgs']} 条 / {e['before_chars']} 字符 → "
f"{e['after_msgs']} 条 / {e['after_chars']} 字符\n\n"
f"摘要预览: {e['summary_preview']}"
)
st.status() 是 Streamlit 1.28+ 才有的组件,专门用来做"这一步在做什么"的可折叠卡片。默认收起,用户想看内部再点开 —— 对 tool call 这种"通常不看、偶尔要看"的信息完美。
user_input = st.chat_input("问问你的笔记 …")
if user_input:
# 立即渲染用户消息(不等 Agent 跑完)
st.session_state.history.append({"role": "user", "content": user_input, "turn": None})
with st.chat_message("user"):
st.markdown(user_input)
# 跑 Agent(有 spinner)
with st.chat_message("assistant"):
with st.spinner("🤔 思考中…"):
result: TurnResult = st.session_state.agent.run_turn(user_input)
st.markdown(result.answer)
render_turn_trace(result)
st.session_state.history.append({
"role": "assistant",
"content": result.answer,
"turn": result, # ← TurnResult 塞进消息里,下次 rerun 也能渲染出工具轨迹
})
st.session_state.session_tokens += result.total_tokens
st.rerun() # 让侧栏的记忆状态刷新
Streamlit 的执行模型可能是初学最容易被绊倒的地方。核心 3 条规则:
| 规则 | 后果 |
|---|---|
| 用户任何交互 → 整个脚本从头跑一遍 | 普通变量不保留,必须放 st.session_state |
| 脚本执行是"顺序渲染",没有事件回调 | 点了按钮想让侧栏刷新?改 state 后 st.rerun() |
| widget(按钮、输入框)的返回值只在"被交互的那一次 rerun"里为 True | 不要用 if st.button() 里的动作依赖后续代码 —— 后面还会再跑一次 |
知道这几条,代码里的 st.rerun() 就都理解了:
st.rerun():让侧栏笔记数量立即刷新st.rerun():把 chunk 数从 0 变成 55st.rerun():让主区的旧消息立刻消失st.rerun():让侧栏的记忆进度条 / token 计数更新st.experimental_rerun(),网上教程一半是新一半是旧。1.27+ 用 st.rerun()。
cd ~/Documents/practice/agents
cp -r day5 day6
cd day6
# 环境(新增 streamlit 依赖)
source venv/bin/activate
pip install "streamlit>=1.30"
# 或者:pip install -r requirements.txt
# 假设 chroma_db/ 从 day5 拷过来了,直接起
streamlit run app.py
浏览器自动打开 http://localhost:8501,看到:
┌────────────────────┬──────────────────────────────────┐
│ 📚 知识库 │ 🤖 知识助手 v3 │
│ │ 基于你私人笔记的问答助手 │
│ 3 篇笔记 · 55 chunk│ │
│ │ [问问你的笔记 …] │
│ 📄 笔记列表 (3) │ │
│ - go-tips.md │ │
│ 🏷️ go · backend│ │
│ 📝 Go 学习备忘 │ │
│ - rag-notes.md │ │
│ - agent-basics │ │
│ │ │
│ 📤 上传笔记 │ │
│ [拖入 .md 文件] │ │
│ [💾 保存][🔄 重建]│ │
│ │ │
│ 🧠 对话记忆 │ │
│ 0 条 / 0 字符 │ │
│ ▓░░░░░░░░░░░ 0% │ │
│ 累计 tokens: 0 │ │
│ [🧹 清空历史] │ │
└────────────────────┴──────────────────────────────────┘
# 1. 老场景不回退
「我笔记里 Go 的错误处理是怎么讲的?」
→ 主区看到答案,下方"第 1 轮 · 调用工具:search_notes"可点开
# 2. 写入工具
「给 go-tips.md 生成一份摘要并保存」
→ 侧栏「笔记列表」展开 go-tips.md,能看到新的 📝 摘要
# 3. 上传新笔记
把电脑上任一 .md 拖进侧栏 → 「💾 保存到 notes/」→「🔄 重建索引」
→ 侧栏笔记数从 3 变成 4;问它新笔记里的内容,能找到
# 4. 触发记忆压缩
继续聊 10+ 轮,观察侧栏进度条爬升;到达阈值那一轮的助手消息底部
会出现蓝色卡片:🧠 记忆压缩:15 条 / 3241 字符 → 6 条 / 812 字符
| 坑 | 解决 |
|---|---|
| 不 rerun,点了按钮后侧栏数据不更新 | state 改完主动 st.rerun() |
KnowledgeAgent() 每次 rerun 都重建,之前的对话记忆全丢 | 用 if "agent" not in st.session_state 守卫 |
教程用了 st.experimental_rerun(),报 AttributeError | Streamlit 1.27+ 改叫 st.rerun() |
| 把 TurnResult 直接扔进 history,rerun 后想再展开轨迹卡片,结果 traceback | dataclass 是可序列化的,能塞进 session_state;出错通常是把 openai response 对象也存了进去 —— 只留 ToolCallTrace 里的字符串字段 |
| 建库 spinner 转了 30s 什么反馈都没有 | with st.spinner(...) 只会在同一 rerun 内显示;耗时操作最好加日志 st.write() 分步给反馈 |
| Ctrl+C 停 streamlit 后再启动,端口占用 | streamlit run app.py --server.port 8502;或 lsof -i :8501 kill 掉 |
侧栏 st.expander 里放太多笔记,页面变卡 | 默认 expanded=False;笔记多的话考虑分页或只显示前 20 条 |
streamlit run app.py 起来后能在浏览器聊天python agent.py)仍能正常跑,行为和 Day 5 一致 —— 证明重构没伤到业务cd ~/Documents/practice/agents
git status
git add day6/
git commit -m "Day 6: Streamlit Web UI(引擎/表现层分离)"
git push
Day 6 结束,你已经有一个完整的、能给别人看的知识助手了。Day 7 会做复盘 + 进阶探索: