Day 6 结束时,我们有一个"能用"的知识助手 —— 但它有两个明显短板:
这两件事其实是同一个更深的问题:Agent 应该像人一样"边想边做"。想清楚 → 决定动作 → 观察结果 → 再想 → 再动。这就是 ReAct pattern(Yao et al., 2022):Reason → Act → Observe 循环。
Reason = LLM 决定调哪个工具;Act = 执行工具;Observe = 工具返回结果喂回 messages。
web_search:Tavily 兜底工具加一个新工具,先想清楚三个问题:
| 问题 | Day 7 的选择 / 理由 |
|---|---|
| 选哪个搜索 API? | Tavily:AI 友好、免费 1000 次/月、返回结构化 JSON + 一句话答案。Serper 也可以,但需要更多后处理。 |
| 是否本地兜底? | 不做。没 TAVILY_API_KEY 就明确报错 —— 教学场景里"静默降级"会掩盖问题。 |
| 怎么让 LLM 知道"什么时候用"? | 写在工具 description 里,而不是 system prompt 里。description 是每次都会传给 LLM 的"隐性 prompt"。 |
httpx.post 就够了def tool_web_search(ctx: ToolContext, query: str, max_results: int = 3) -> str:
api_key = os.getenv("TAVILY_API_KEY")
if not api_key:
return "ERROR: 未配置 TAVILY_API_KEY。请在 .env 里加一行 `TAVILY_API_KEY=tvly-xxx`(去 tavily.com 免费申请)。"
resp = httpx.post(
"https://api.tavily.com/search",
json={
"api_key": api_key,
"query": query,
"max_results": max_results,
"search_depth": "basic", # basic 便宜快;advanced 才用 credit
"include_answer": True, # Tavily 顺手给一句 LLM 摘要
},
timeout=15.0,
)
resp.raise_for_status()
data = resp.json()
results = data.get("results") or []
lines = [f"🌐 联网搜索: '{query}',返回 {len(results)} 条结果:"]
if answer := data.get("answer"):
lines.append(f"\n📌 Tavily 一句话答案: {answer}\n")
for i, r in enumerate(results, 1):
content = r.get("content") or ""
if len(content) > 500:
content = content[:500] + "…"
lines.append(f"\n[{i}] {r.get('title')}\n URL: {r.get('url')}\n 摘要: {content}")
return "\n".join(lines)
content 可能就是一整篇文章。三条不截断的话,一次 tool return 就能塞 5000+ token,把 context 撑爆。
工具的 description 是隐性 prompt,比 system prompt 更贴身:
{
"type": "function",
"function": {
"name": "web_search",
"description": (
"联网搜索(Tavily)。**兜底工具**:只在本地笔记里明确没有相关内容"
"(比如你先用 search_notes 搜过、相似度都 < 0.4,或者用户明确问的是"
"笔记里不可能有的事,如今天的新闻、某个库的最新版本)时才使用。"
"不要一上来就 web_search —— 用户笔记里可能有更贴合他自己上下文的答案。"
),
...
}
}
实测这条 description 效果比在 system prompt 里"教育"强 —— 因为它在 tool_calls 生成的那一刻就摆在 LLM 眼前。
Thought 的经典 ReAct paper 做法是让 LLM 输出这种格式:
Thought: 我需要先查一下笔记
Action: search_notes
Action Input: {"query": "Go 错误处理"}
Observation: ...
Thought: 笔记里没相关内容,我要联网
Action: web_search
...
但那是 2022 年的做法 —— 当时 LLM 还没有原生 tool_calls。今天有 tool_calls,我们不需要重新发明格式。做法更简单:
content 和 tool_calls。这样零协议改动、零 prompt 语法负担。
Step 1:把 thought 加到 RoundTrace
@dataclass
class RoundTrace:
round_num: int
thought: str = "" # 🆕 Day 7
tool_calls: list[ToolCallTrace] = field(default_factory=list)
assistant_text: str = ""
prompt_tokens: int = 0
completion_tokens: int = 0
Step 2:主循环里抓 content
msg = response.choices[0].message
self.memory.append(msg)
if not msg.tool_calls:
# 最终答案
round_trace.assistant_text = msg.content or "(无回复)"
...
# 有 tool_calls:content 就是 Thought
round_trace.thought = (msg.content or "").strip()
Step 3:system prompt 里"教育" LLM 每次都写
⚠️ 关键:每次调用工具时,assistant 消息的 content 字段必须非空。用一句中文写清楚
"我打算做什么、为什么"(这就是 Thought / ReAct 的 Reasoning 部分)。这条要严格遵守,
便于用户看到你的推理。示例:
content = "先在笔记里搜 Go 错误处理,看看 go-tips.md 里怎么讲。"
tool_calls = [{name: "search_notes", ...}]
不要输出空 content 就调工具,即使你觉得下一步很显然。
reasoning_content:把 CoT 从模型里"抠"出来Step 3 是理论 —— 现实是有些模型就是不听话。第一次跑,Thought 全是空字符串:
--- Round 1 ---
thought = ''
tool: search_notes({"query":"Go 错误处理","top_k":3})
--- Round 2 ---
thought = ''
tool: read_full_note({"filename":"go-tips.md"})
翻 Doubao 的响应发现,它把 CoT 放在另一个字段 reasoning_content 里 —— 这是 Doubao / DeepSeek 系模型的原生"思考链"字段(类似 OpenAI o1 的 reasoning tokens),跟 content 平级但独立。
gpt-4o、Claude 的 sonnet 会老实把 Thought 放在 content 里;Doubao / DeepSeek-R1 会把它拆到 reasoning_content。要写通用代码就得两个都拿。
# 有 tool_calls:这轮的 content 就是"思考"
# 兼容:ARK/Doubao 系模型可能把推理放在 reasoning_content 字段里(有时会很长,
# 我们截首段做为 Thought 展示,完整 CoT 通常几百上千字,UI 里塞不下也没必要)。
thought = (msg.content or "").strip()
if not thought:
raw = (getattr(msg, "reasoning_content", None) or "").strip()
if raw:
# 取首个"段落"(第一个换行前),再截 240 字
first_para = raw.split("\n", 1)[0].strip()
thought = first_para[:240] + ("…" if len(first_para) > 240 else "")
round_trace.thought = thought
再跑一次,Thought 出来了:
--- Round 1 ---
thought = '先在笔记里搜索Python 3.13的新特性,看看是否有相关记录。'
tool: search_notes({"query":"Python 3.13 新特性","top_k":3})
--- Round 2 ---
thought = '我看了下搜索结果,笔记里都是Go相关的内容,和Python 3.13无关。现在需要联网搜索最新信息。'
tool: web_search({"query":"Python 3.13 新特性","max_results":3})
第二轮的 Thought 完美地解释了 "为什么切换工具" —— 这就是 ReAct 的价值:用户能看见决策链路。
只改一行 render_turn_trace,在工具卡片顶端加一条 💭 思考:
with st.status(f"第 {r.round_num} 轮 · 调用工具:{tool_names}", ...):
# 💭 Thought:Day 7 新增
if r.thought:
st.markdown(f"💭 **思考**:{r.thought}")
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.subheader("🌐 联网工具")
if os.getenv("TAVILY_API_KEY"):
st.success("Tavily 已配置")
st.caption("Agent 会在本地笔记搜不到时自动兜底")
else:
st.warning("未配置 TAVILY_API_KEY")
st.caption("去 [tavily.com](https://tavily.com) 免费申请 (1000 次/月)")
cd ~/Documents/practice/agents
cp -r day6 day7
cd day7
# 可选:申请 Tavily key(不申请也能跑,Agent 会优雅报错)
# 编辑 .env 加一行: TAVILY_API_KEY=tvly-xxx
source venv/bin/activate # 或者 python3 -m venv venv && pip install -r requirements.txt
streamlit run app.py
# ── A. 纯本地问题(不应联网)
"我笔记里 Go 错误处理是怎么讲的?"
→ Round 1 💭 "先搜 Go 错误处理" → search_notes 命中 → 回答带 [来源: go-tips.md]
# ── B. 纯联网问题(应该先搜本地 → 落空 → 联网)
"Python 3.13 有什么新特性?"
→ Round 1 💭 "先在笔记里查一下" → search_notes 相似度都 < 0.5
→ Round 2 💭 "笔记里没有,联网搜" → web_search
→ 回答带 [来源: URL]
# ── C. 混合问题
"我笔记里讲的 RAG 和 LangChain 官方文档里的 RAG 有什么区别?"
→ 应该看到 search_notes + web_search 都被调用
┌ 第 2 轮 · 调用工具:web_search ────────────────── ▼
│
│ 💭 思考:笔记里都是 Go 相关的内容,和 Python 3.13 无关。现在需要联网搜索最新信息。
│
│ 🔧 web_search
│ {"query":"Python 3.13 新特性","max_results":3}
│
│ 返回:
│ 🌐 联网搜索: 'Python 3.13 新特性',返回 3 条结果:
│ 📌 Tavily 一句话答案: Python 3.13 引入了 JIT、无 GIL 模式(实验)、REPL 增强……
│ [1] What's New In Python 3.13
│ URL: https://docs.python.org/3/whatsnew/3.13.html
│ 摘要: ...
└─────────────────────────────────────────────────
| 坑 | 解决 |
|---|---|
| Doubao 的 tool_call 消息 content 是空的,Thought 抓不到 | fallback 到 msg.reasoning_content(Doubao/DeepSeek 系模型的原生 CoT 字段) |
reasoning_content 有时长达 800+ 字,UI 塞不下 | 取首段(第一个 \n 前)+ 截 240 字,尾部加 … |
没 TAVILY_API_KEY 时 httpx 401 报错难看 | 入口就检查 env 变量,明确告诉用户去哪申请 |
Tavily 单条 content 可能 3000+ 字,一次搜索塞爆 context | 每条截 500 字 |
| LLM 跳过本地搜索直接联网 | 在 web_search 的 description 里明确"兜底用"、"不要一上来就 web_search" |
从 day6 拷 venv 过来路径断了,dyld 报错 | rebuild:rm -rf venv && python3 -m venv venv && pip install -r requirements.txt |
Thought 想用 XML <thought> 标签解析 → 复杂 | 不需要!直接用 assistant 消息的 content 字段就行,OpenAI 允许它和 tool_calls 共存 |
search_notes,不联网search_notes 落空 → 再 web_search,两轮的 Thought 都能看到web_search 返回明确的错误提示,Agent 承认失败,不瞎编python agent.py 也能看到 💭 前缀的 Thought七天下来,从"没写过 LLM 代码"到有一个 RAG + Tool Use + 记忆压缩 + ReAct + 联网兜底 的私人知识助手。以下是复盘要点(完整版见 day7/REVIEW.md):
| Day | 主题 | 一句话学到 |
|---|---|---|
| 1 | LLM 首次调用 | messages 就是一个 role+content 数组,历史全靠客户端拼 |
| 2 | Tool Use | 工具调用是"LLM 返回 JSON → 你解析 → 你执行 → 再喂回去"的循环 |
| 3 | RAG | 相似度 < 0.4 就是"没找到",比阈值瞎调关键词有用 |
| 4 | 组装 v1 | Agent = 工具能力 × 组合方式;工具 description 是隐性 prompt |
| 5 | 记忆增强 | 切分轮次不能切在 tool_call 中间;元数据要跟数据走 |
| 6 | Streamlit Web UI | 类里出现 print 就是没拆干净;UI 应该消费结构化数据 |
| 7 | ReAct + 联网 | Thought 靠 prompt 教 + reasoning_content fallback 两条腿走 |
✅ 现在能做的:私人笔记问答、多轮上下文(自动压缩不炸)、反向整理笔记(tag / summary)、本地找不到时联网兜底、CLI / Web 两个前端零重复、每轮工具调用轨迹 + Thought 可解释。
❌ 还不能做的(下一步方向):
max_rounds = 8 会截断)add_tag / summarize_note UI 弹确认框st.write_stream 逐字显示pypdf + 现有 chunking 流程cd ~/Documents/practice/agents
git status
git add day7/
git commit -m "Day 7: ReAct + web_search + REVIEW"
git push
这个仓库到 Day 7 就是一个稳定的里程碑。接下来的方向已经在上面列了 —— 挑一个最想做的开始就好,不用按顺序。
如果你从这一周开始 fork 或复现,欢迎在 Issues 里聊聊你踩到的坑和改造的方向。