从 @tool 到四层架构:一个查天气 Agent 的拆解

写一个会调工具的 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}

tipslast_rawfield(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 告诉对方这一轮结束(收)。换个场景,比如订单也是一样的------先认清是谁下的单,再算折扣查库存,进度一路推,最后落库并发完成。

相关推荐
苏灿烤鱼1 小时前
十个 CLI 坐进一间办公室,协调层靠得住吗?
typescript·github·agent
武子康2 小时前
Project Trust 不是 Sandbox:Pi Agent 的安全边界怎样补齐
人工智能·llm·agent
武子康3 小时前
DeepSeek Harness:Subagent、Job、Goal 都叫任务,为什么不能混成一个对象
人工智能·llm·agent
xiezhr3 小时前
DeepSeek Harness 值得安装的 15 款插件
github·agent·deepseek
AlienZHOU10 小时前
DeepSeek Harness 插件:HTML 实时可视化编辑
前端·agent·deepseek
怕浪猫12 小时前
DeepSeek Harness 源码实战第3章:Profile / Bundle / Patch——dsh 的装配系统
openai·agent·ai编程
冬奇Lab14 小时前
Code Agent 解剖(04):系统提示词是怎么组装的,agent 的「人格」从哪来?
人工智能·开源·agent
一点一木16 小时前
Hermes Desktop 重磅更新,Bot Mode 正式上线——把你的 AI 变成一支可协作的专属团队
人工智能·agent