写一个会调工具的 AI 助手,LangChain 里最小写法是很简单的:@tool 挂能力,再 create_agent 让模型选题型。能跑,但一加上流式、落库、防串密钥,单文件就会搅成一团。
本篇以查天气的 agent 为例:先写最小版,再拆成 装、干、推、收 四层(Context / Tools / Runner / Persist)。天气客户端始终是假的;调用工具会用真 Agent(需要 API Key)。
一、最小写法:@tool + create_agent
一个文件搞定。密钥先用全局变量。
依赖:
text
langchain>=1.0
langchain-openai
langchain-core
python-dotenv
创建 weather_min.py:
python
import os
from dotenv import load_dotenv
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
load_dotenv()
WEATHER_KEY = "sk-weather-secret"
DEFAULT_CITY = "上海"
class FakeWeatherClient:
def __init__(self, api_key: str):
self.api_key = api_key
def fetch(self, city: str) -> dict:
return {"city": city, "text": "晴,22℃", "ok": True}
@tool
def get_weather(city: str = "") -> str:
"""查指定城市天气。城市为空则用默认城市。"""
name = (city or "").strip() or DEFAULT_CITY
raw = FakeWeatherClient(WEATHER_KEY).fetch(name)
return f"{raw['city']}:{raw['text']}"
llm = ChatOpenAI(
model=os.getenv("CHAT_MODEL", "deepseek-v4-pro"),
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
)
agent = create_agent(
model=llm,
tools=[get_weather],
system_prompt="你是天气助手。问天气必须调用 get_weather,用中文简短回答。",
)
if __name__ == "__main__":
result = agent.invoke(
{"messages": [{"role": "user", "content": "北京天气怎么样"}]}
)
# 从最终 messages 里取助手回复(不同版本结构略有差异)
msgs = result.get("messages") or []
print(msgs[-1].content if msgs else result)
.env 里配好 DEEPSEEK_API_KEY后:
bash
python weather_min.py
模型会选工具调 get_weather,你能看到一句答案。
二、最小写法卡在哪
真要接产品,单文件会连续撞坑:
| 需求 | 最小写法会怎样 |
|---|---|
| 密钥 / 默认城市按请求变 | 全局变量,多用户易串;更糟的是有人把 api_key 写进工具参数,模型可见 |
| 边生成边看、还要进度 | invoke 整段返回;astream 不分流时,工具内部草稿可能漏进气泡 |
| 落库、引用、告诉前端结束 | 答案和要落库的材料无处统一收;没有 finished,前端不知道何时停 |
说白了:create_agent 只帮你做了选题型调工具这一截。装凭证、推流过滤、收束落库,框架不会自动替你拆好。
所以要按职责切开------装、干、推、收:
#mermaid-svg-Xfdi5bDRn0hWvpxV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Xfdi5bDRn0hWvpxV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Xfdi5bDRn0hWvpxV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Xfdi5bDRn0hWvpxV .error-icon{fill:#552222;}#mermaid-svg-Xfdi5bDRn0hWvpxV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Xfdi5bDRn0hWvpxV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Xfdi5bDRn0hWvpxV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Xfdi5bDRn0hWvpxV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Xfdi5bDRn0hWvpxV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Xfdi5bDRn0hWvpxV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Xfdi5bDRn0hWvpxV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Xfdi5bDRn0hWvpxV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Xfdi5bDRn0hWvpxV .marker.cross{stroke:#333333;}#mermaid-svg-Xfdi5bDRn0hWvpxV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Xfdi5bDRn0hWvpxV p{margin:0;}#mermaid-svg-Xfdi5bDRn0hWvpxV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Xfdi5bDRn0hWvpxV .cluster-label text{fill:#333;}#mermaid-svg-Xfdi5bDRn0hWvpxV .cluster-label span{color:#333;}#mermaid-svg-Xfdi5bDRn0hWvpxV .cluster-label span p{background-color:transparent;}#mermaid-svg-Xfdi5bDRn0hWvpxV .label text,#mermaid-svg-Xfdi5bDRn0hWvpxV span{fill:#333;color:#333;}#mermaid-svg-Xfdi5bDRn0hWvpxV .node rect,#mermaid-svg-Xfdi5bDRn0hWvpxV .node circle,#mermaid-svg-Xfdi5bDRn0hWvpxV .node ellipse,#mermaid-svg-Xfdi5bDRn0hWvpxV .node polygon,#mermaid-svg-Xfdi5bDRn0hWvpxV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Xfdi5bDRn0hWvpxV .rough-node .label text,#mermaid-svg-Xfdi5bDRn0hWvpxV .node .label text,#mermaid-svg-Xfdi5bDRn0hWvpxV .image-shape .label,#mermaid-svg-Xfdi5bDRn0hWvpxV .icon-shape .label{text-anchor:middle;}#mermaid-svg-Xfdi5bDRn0hWvpxV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Xfdi5bDRn0hWvpxV .rough-node .label,#mermaid-svg-Xfdi5bDRn0hWvpxV .node .label,#mermaid-svg-Xfdi5bDRn0hWvpxV .image-shape .label,#mermaid-svg-Xfdi5bDRn0hWvpxV .icon-shape .label{text-align:center;}#mermaid-svg-Xfdi5bDRn0hWvpxV .node.clickable{cursor:pointer;}#mermaid-svg-Xfdi5bDRn0hWvpxV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Xfdi5bDRn0hWvpxV .arrowheadPath{fill:#333333;}#mermaid-svg-Xfdi5bDRn0hWvpxV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Xfdi5bDRn0hWvpxV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Xfdi5bDRn0hWvpxV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Xfdi5bDRn0hWvpxV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Xfdi5bDRn0hWvpxV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Xfdi5bDRn0hWvpxV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Xfdi5bDRn0hWvpxV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Xfdi5bDRn0hWvpxV .cluster text{fill:#333;}#mermaid-svg-Xfdi5bDRn0hWvpxV .cluster span{color:#333;}#mermaid-svg-Xfdi5bDRn0hWvpxV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Xfdi5bDRn0hWvpxV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Xfdi5bDRn0hWvpxV rect.text{fill:none;stroke-width:0;}#mermaid-svg-Xfdi5bDRn0hWvpxV .icon-shape,#mermaid-svg-Xfdi5bDRn0hWvpxV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Xfdi5bDRn0hWvpxV .icon-shape p,#mermaid-svg-Xfdi5bDRn0hWvpxV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Xfdi5bDRn0hWvpxV .icon-shape .label rect,#mermaid-svg-Xfdi5bDRn0hWvpxV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Xfdi5bDRn0hWvpxV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Xfdi5bDRn0hWvpxV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Xfdi5bDRn0hWvpxV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} weather_min 单文件
Context 装
Tools 干
Runner 推
Persist 收
下面把刚才那个文件,一步步拆开。假天气客户端保留;Runner 里继续用 create_agent,只是流式和落库补上。
三、拆成四层
text
weather_agent/
├── context.py
├── tools.py
├── runner.py
└── main.py
bash
mkdir weather_agent && cd weather_agent
3.1 Context:装进请求上下文
把这一趟要用的东西从全局装进请求上下文,一次请求一份。
创建 context.py:
python
from dataclasses import dataclass, field
@dataclass
class WeatherContext:
api_client: object
default_city: str = "上海"
last_raw: list = field(default_factory=list)
class FakeWeatherClient:
def __init__(self, api_key: str):
self.api_key = api_key
def fetch(self, city: str) -> dict:
return {"city": city, "text": "晴,22℃", "ok": True}
tips :
last_raw用field(default_factory=list),别写=[]。
相对最小版:WEATHER_KEY / DEFAULT_CITY 不再躺在模块全局里等人使用。
3.2 Tools:干活(闭包注入)
工具签名只留题面;Key、默认城市、写回槽位都从 ctx 读。
创建 tools.py:
python
from langchain_core.tools import tool
from context import WeatherContext
def build_tools(ctx: WeatherContext) -> list:
@tool
def get_weather(city: str = "") -> str:
"""查指定城市天气。城市为空则用默认城市。"""
name = (city or "").strip() or ctx.default_city
raw = ctx.api_client.fetch(name)
ctx.last_raw.append(raw) # 原始 JSON 先攒着;字吐完 Persist 再拿去落库/审计
if not raw.get("ok"):
return f"没查到 {name} 的天气。"
return f"{raw['city']}:{raw['text']}"
return [get_weather]
3.3 Runner:推给用户
这里仍是 create_agent,先立住对外只出事件:进度一路、正文一路;工具材料不外泄。
创建 runner.py:
python
import os
from collections.abc import Iterator
from dotenv import load_dotenv
from langchain.agents import create_agent
from langchain_core.messages import HumanMessage
from langchain_openai import ChatOpenAI
from context import WeatherContext
from tools import build_tools
load_dotenv()
def run_agent_stream(question: str, ctx: WeatherContext) -> Iterator[dict]:
"""yield kind=tool_running | answer_delta"""
tools = build_tools(ctx)
llm = ChatOpenAI(
model=os.getenv("CHAT_MODEL", "deepseek-v4-pro"),
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
)
agent = create_agent(
model=llm,
tools=tools,
system_prompt="你是天气助手。问天气必须调用 get_weather,用中文简短回答。",
)
# 简化示意:真项目一般用 astream(stream_mode=["updates","messages"]) 分流
# 这里用 invoke 拿终稿,但事件形态仍拆成进度 → 正文,方便对照四层
yield {"kind": "tool_running", "content": "正在查询天气..."}
result = agent.invoke({"messages": [HumanMessage(content=question)]})
msgs = result.get("messages") or []
text = getattr(msgs[-1], "content", "") if msgs else ""
for ch in str(text or ""):
yield {"kind": "answer_delta", "content": ch}
相对最小版:选题还在 Agent,但对外不再是直接 print 最终 messages,而是统一事件流。
3.4 Persist:收尾
最小写法是 invoke 完直接 print 最后一句,终端看一眼就丢了。正式项目几乎都要落库:完整答案、用户是谁、工具原始结果,刷新还在、出了问题能追溯。脚本 Demo 可以没有这一层,上线就要有。
顺序是 Runner 推字 → Persist 拼全文、写库 → 再发结束信号 。本例用 print 代替真正的 INSERT。
结束靠 finished: true 这一个字段:正文已在 answer_delta 里推过,结束包 content 留空(没有新字可拼),只带本例的 answer_text / raw / user_id;换成知识库再加 citations。上线后前端不能靠连接断了猜结束,要等这条信号。
创建 main.py:
python
from context import WeatherContext, FakeWeatherClient
from runner import run_agent_stream
def handle_request(question: str, user_id: str) -> list[dict]:
ctx = WeatherContext(
api_client=FakeWeatherClient(api_key="sk-weather-secret"),
default_city="上海",
)
full: list[str] = []
events: list[dict] = []
for ev in run_agent_stream(question, ctx):
events.append(ev)
if ev["kind"] == "answer_delta":
full.append(ev["content"])
answer_text = "".join(full)
events.append({
"finished": True,
"content": "",
"answer_text": answer_text,
"raw": list(ctx.last_raw),
"user_id": user_id,
})
return events
if __name__ == "__main__":
events = handle_request("北京天气怎么样", user_id="u1")
for e in events:
if e.get("finished"):
print(f"\n[finished] {e.get('raw')}")
print("落库答案:", e.get("answer_text"))
print("审计 raw:", e.get("raw"))
elif e.get("kind") == "answer_delta":
print(e["content"], end="", flush=True)
elif e.get("kind") == "tool_running":
print(f"[{e['kind']}] {e['content']}")
终端你看到的顺序是:进度 → 逐字正文 → finished 相关信息。
四、最小版 vs 四层
| 原来(单文件) | 拆完落在哪 | 解决了什么 |
|---|---|---|
全局 WEATHER_KEY / DEFAULT_CITY |
Context | 按请求装进请求上下文,不串 |
@tool get_weather |
Tools + 闭包 | 题面参数,密钥不进 schema |
create_agent + invoke + print |
Runner 出事件 | 进度 / 正文可分开推 |
| 无 | Persist | finished + last_raw 一次带走 |
装 状态,干 能力,推 给用户看的管道,收 落库与结束信号。create_agent 主要落在干 + 推里的选题与生成;装和收仍要你自己写。
五、总结
四层把密钥、流式观感、落库收束拆开,如果查天气换成其他场景(比如知识库),只换请求上下文的字段和工具名就行了。
装、干、推、收对应的是一次请求从进到出的四段 ,:用户一问进来,先把这一趟要用的密钥、默认城市装进请求上下文(装),再让工具真去查天气(干),然后边算边把进度和正文推给前端(推),最后字齐了再落库,并打出 finished: true 告诉对方这一轮结束(收)。换个场景,比如订单也是一样的------先认清是谁下的单,再算折扣查库存,进度一路推,最后落库并发完成。