2026年的 ReAct Agent架构解析:原生 Tool Calling 与 LangGraph 状态机

简介: 本文介绍2026年演进版ReAct架构下的Research Brief Agent:摒弃脆弱的字符串解析(如"Thought:/Action:"),采用原生结构化工具调用(JSON Schema)、消息账本式State管理、自动引用提取与Postgres持久化,实现可复现、可审计、带真实URL引用的自动化研究简报生成。

ReAct(Reason + Act)架构要解决的问题是开放式研究里最经典的问题。本文要做的是一个 Research Brief Agent:会上网搜索、抓取真实 URL、压缩证据,最终产出一份带真实引用的结构化简报。重点不在于功能,而在于 正确写法——不再依赖那种脆弱的 "Thought: / Action:" 字符串解析。

早期 ReAct 留下的问题

ReAct 论文最初证明了,让 LLM 在动手之前先把推理写出来,效果会明显更好。

那时候的实现可以说就是 prompt hack。给模型一段这样的提示:

You have access to tools. You must use this format: Thought: [your thought], Action: [tool_name], Action Input: [tool_input].

模型吐回一段字符串,Python 用正则去抠工具名和参数,工具运行的结果再以

Observation: [result]

的形式拼回 prompt 里。

demo 阶段勉强能跑到了生产环境就问题成堆。模型对格式的幻觉源源不断:一会儿漏掉

Action Input:

前缀,一会儿调用一个根本不存在的工具,正则当场就崩。

2026 年的 ReAct:原生工具调用

这套写法早就被淘汰了,但Reason、Act、Observe 这三段核心节奏依然成立,只是执行模型完全换了一种思路。

现在的工具使用系统不再做字符串解析,而是原生的、结构化的 API tool calling。schema 校验由 LLM 提供方负责——OpenAI、Anthropic、Google 都是如此——严格性放在他们那一侧。

新的 ReAct 循环大致是这样:

  1. Reason:LLM 看一遍会话历史,判断还缺什么信息。
  2. Act:LLM 发出一段严格的 JSON tool call payload,例如 {"name": "search_web", "arguments": {"query": "react agent failures"}}
  3. Observe:LangGraph 运行时执行工具,把带有结果的 ToolMessage 追加回 state。

循环一直跑到 LLM 觉得证据足够,然后输出一段普通的文本回复,而不是再发一个 tool call。

Research Brief Agent:State 与 Schema

动手开始写。第一件事是定义 state schema。

确定性 workflow 里的 state 通常是一组离散字段——

raw_diff

has_critical_findings

之类。但开放式 ReAct 循环里,state 主要表现为一份 append-only 的消息账本。

只有消息还不够。引用也要追踪:不光要让 LLM 写出一段总结,还得拿到一份能在 UI 里渲染的具体引用列表。

 from typing import Annotated, TypedDict  
from langchain_core.messages import BaseMessage  
from langgraph.graph.message import add_messages  
import operator  

class ResearchState(TypedDict):  
    topic: str  

    # 会话的核心账本
    messages: Annotated[list[BaseMessage], add_messages]  

    # 在循环过程中累积起来的证据库
    citations: Annotated[list[dict[str, str]], operator.add]  
    seen_urls: Annotated[list[str], operator.add]  

    # 防止无限循环的控制变量
    step_count: int  
    max_steps: int  
    stagnant_turns: int  

     final_brief: str

注意 reducer 的作用,

add_messages

让新消息追加而不是覆盖,

operator.add

给 citations 和 URL 列表做的是同样的事。在循环里维护历史,靠的就是这两件小工具。

Search 与 Fetch

把图连起来之前得先有工具,一个常见错误是给 agent 一个返回完整原始 HTML 的工具——第一轮循环还没结束,上下文窗口就已经被冲爆。

下面两个普通的 HTTP 工具就够了:

search_web

找候选链接,

fetch_url

拉真正的正文。

 import json  
import urllib.request  
import urllib.parse  
from langchain_core.tools import tool  
import re  
import html  

def _http_get(url: str, timeout: int = 12) -> str:  
    with httpx.Client(timeout=timeout, headers={"User-Agent": "Mozilla/5.0"}) as client:  
        response = client.get(url)  
        response.raise_for_status()  
        return response.text  

@tool  
def search_web(query: str, max_results: int = 5) -> str:  
    """Search the web via DuckDuckGo Instant Answer API. Returns JSON list."""  
    try:  
        params = urllib.parse.urlencode({"q": query, "format": "json"})  
        payload = _http_get(f"https://api.duckduckgo.com/?{params}")  
        data = json.loads(payload)  

        # ...(提取 URL 与 snippet 的解析逻辑)...
        # 简洁起见,假设这里返回的 JSON 字符串结构为:
        # [{"url": "...", "title": "...", "snippet": "..."}]

        return json.dumps(results[:max_results])  
    except Exception as exc:  
        return json.dumps([{"url": "", "title": "error", "snippet": str(exc)}])  

@tool  
def fetch_url(url: str) -> str:  
    """Fetch and compress a URL into JSON: {url,title,snippet}."""  
    try:  
        raw_html = _http_get(url)  

        # 剥掉 script、style 与 HTML 标签,留下纯文本
        no_script = re.sub(r"<script[^>]*>.*?</script>", " ", raw_html, flags=re.IGNORECASE | re.DOTALL)  
        no_style = re.sub(r"<style[^>]*>.*?</style>", " ", no_script, flags=re.IGNORECASE | re.DOTALL)  
        text = re.sub(r"<[^>]+>", " ", no_style)  
        clean_text = html.unescape(re.sub(r"\s+", " ", text)).strip()  

        # 只取前 2000 个字符,省一点上下文
        return json.dumps({"url": url, "title": "Fetched Page", "snippet": clean_text[:2000]})  
    except Exception as exc:  
         return json.dumps({"url": url, "title": "error", "snippet": str(exc)})

错误处理的写法值得多看一眼:

httpx

抛超时,捕获之后把异常转成 JSON 字符串返回。一个失效链接不应当能整死一整张图的执行。错误以字符串形式回流,LLM 把它读作一次 observation,自然会去尝试别的链接。

图的形状:比你想的小

ReAct 图的结构出乎意料地简单——本质上只要两个主节点:一个让 LLM 推理,一个执行工具。

节点和路由是这样写的:

 from langgraph.graph import StateGraph, START, END  
from langgraph.prebuilt import ToolNode  
from langchain_core.messages import SystemMessage, AIMessage  

async def reason_node(state: ResearchState, llm_with_tools) -> dict:  
    system = SystemMessage(  
        content=(  
            "You are a research brief agent. Use tools to gather evidence and cite URLs. "  
            "Prefer search first, then fetch_url for top links. "  
            "When you have enough evidence, return a concise final brief. "  
            "Never fabricate citations."  
        )  
    )  
    # LLM 看一遍完整消息历史,再决定下一步动作
    response = await llm_with_tools.ainvoke([system] + state["messages"])  

    return {  
        "messages": [response],  
        "step_count": state["step_count"] + 1,  
    }  

def route_after_reason(state: ResearchState) -> str:  
    # 硬停止条件
    if state["step_count"] >= state["max_steps"]:  
        return "finalize"  

    last_message = state["messages"][-1]  

    # LLM 决定调用工具,则导向工具执行节点
    if isinstance(last_message, AIMessage) and last_message.tool_calls:  
        return "tools"  

    # 没有 tool call,说明 LLM 认为研究阶段结束
     return "finalize"
reason_node

是认知工作真正发生的地方,整段消息历史会一次性喂给模型;信息不够时,模型会回一条带

tool_calls

AIMessage

路由函数

route_after_reason

检查这条消息:包含 tool calls 就把图切到

tools

节点。LangGraph 提供了一个预置的

ToolNode

,它会自动解包参数、执行 Python 函数,并产出

ToolMessage

形式的 observation。

拦截器:清洗 Observation

如果让

ToolNode

把结果直接倒回 state 就此打住,等于错失了一个机会。引用应该在过程中就抽出来,不必等到写最终简报时再让 LLM 凭记忆复盘每一个 URL。

工具节点之后再加一个

sanitize

节点。

 import json  

def sanitize_observation_node(state: ResearchState) -> dict:  
    # 取最近的 tool messages
    recent_messages = state["messages"][-3:]  

    new_citations = []  
    new_urls = []  

    for msg in recent_messages:  
        if getattr(msg, "type", "") != "tool":  
            continue  

        try:  
            # 解析工具返回的 JSON 字符串
            data = json.loads(str(msg.content))  
            items = data if isinstance(data, list) else [data]  

            for item in items:  
                url = item.get("url", "").strip()  
                if url and url not in state["seen_urls"]:  
                    new_urls.append(url)  
                    new_citations.append({  
                        "url": url,  
                        /* Lines 230-231 omitted */  
                        "snippet": item.get("snippet", "")  
                    })  
        except Exception:  
            pass  

    # 跟踪 agent 是否陷入找不到任何新内容的状态
    stagnant_turns = state["stagnant_turns"] + 1 if not new_urls else 0  

    return {  
        "citations": new_citations,  
        "seen_urls": new_urls,  
        "stagnant_turns": stagnant_turns,  
     }

这个节点拦下原始的工具输出做一次解析,更新结构化的

citations

账本,顺手也维护

stagnant_turns

。Agent 连续三轮没找到任何新 URL,那就是卡住了。

持久化与可重放

一个开放式 agent 的最大一个问题是无法收敛。你问的是 2026 年的实践,它一头扎进 2018 年的 API 文档——这种情况发生时,你需要清楚地知道是哪些搜索结果把它带歪的。

State 不过是一份消息列表,不持久化的话,脚本一退出就什么都没了。前面几篇文章里造好的 Postgres checkpointer 现在派上用场。

 import hashlib  
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver  
from psycopg_pool import AsyncConnectionPool  

def topic_to_thread_id(topic: str) -> str:  
    digest = hashlib.sha1(topic.encode("utf-8")).hexdigest()[:10]  
    return f"research-{digest}"  

# 在 run 函数内部:
db_uri = "postgresql://postgres:postgres@localhost:5432/postgres"  
async with AsyncConnectionPool(conninfo=db_uri, max_size=10) as pool:  
    checkpointer = AsyncPostgresSaver(pool)  
    await checkpointer.setup()  

    app = graph.compile(checkpointer=checkpointer)  

    thread_id = topic_to_thread_id(topic)  
    config = {"configurable": {"thread_id": thread_id}}  

     # 运行图……

把 topic 哈希成

thread_id

,相同主题二次运行时就会从中断点续上,而不是从零开跑。还有一个更现实的好处:整个 ReAct 循环会在数据库里留下完整可查询的记录。

把图拼起来

各部分拼起来之后,整个 workflow 看起来很顺:agent 推理,工具执行,state 被清洗,循环再来一遍。条件触发后,落到 finalization 阶段。

 async def finalize_node(state: ResearchState, llm) -> dict:  
    # 从清洗过的账本里组一段干净的证据块
    citation_block = "\n".join(  
        f"- {c['title']} | {c['url']} | {c['snippet'][:180]}"  
        for c in state["citations"][:8]  
    )  

    prompt = (  
        f"Topic: {state['topic']}\n\n"  
        "Write a concise research brief in 8-12 bullets.\n"  
        "Each bullet should be evidence-backed where possible.\n"  
        "Then add a short 'Citations' section with URL list only.\n\n"  
        f"Evidence:\n{citation_block if citation_block else '- No citations collected.'}"  
    )  

    response = await llm.ainvoke([HumanMessage(content=prompt)])  

    return {  
        "final_brief": str(response.content),  
    }  

# 构建执行图
def build_graph(tools, llm):  
    llm_with_tools = llm.bind_tools(tools)  
    tool_node = ToolNode(tools)  

    builder = StateGraph(ResearchState)  

    builder.add_node("reason", lambda state: reason_node(state, llm_with_tools))  
    builder.add_node("tools", tool_node)  
    builder.add_node("sanitize", sanitize_observation_node)  
    builder.add_node("finalize", lambda state: finalize_node(state, llm))  

    builder.add_edge(START, "reason")  
    builder.add_conditional_edges(  
        "reason",   
        route_after_reason,   
        {"tools": "tools", "finalize": "finalize"}  
    )  
    builder.add_edge("tools", "sanitize")  

    # 用于检查是否停滞的另一条条件边
    def route_after_sanitize(state):  
        if state["step_count"] >= state["max_steps"] or state["stagnant_turns"] >= 3:  
            return "finalize"  
        return "reason"  

    builder.add_conditional_edges(  
        "sanitize",   
        route_after_sanitize,   
        {"reason": "reason", "finalize": "finalize"}  
    )  
    builder.add_edge("finalize", END)  

     return builder

第 100 次工具调用问题

这段代码现在跑起来,多数时候表现得很漂亮:搜索、抓几个页面、写出一份扎实的简报。

但是有时候它就根本停不下来。

LLM 会判定手头的信息还差点意思,于是换个略不一样的关键词再搜一次,再抓一个页面,再搜一次。每次新增的

ToolMessage

都在拉长上下文窗口,agent 渐渐失焦被自己堆出来的海量文本搞混了。

循环不加约束的后果非常具体——一个 agent 可以心安理得地连发 100 次 tool call,把 API 预算烧光,最后在超过模型最大 token 限制时直接挂掉。

路由里那个

max_steps

检查只是基础款;

stagnant_turns

用来抓住卡住的瞬间。生产环境要的远不止这些:实时监控 token 用量、为特定工具加熔断器(circuit breaker)、给 agent 留一个 "逃生舱" 在彻底找不到答案时去找人介入——一整套约束都得跟上。

完整可运行代码

下面是 Research Brief Agent 的完整脚本。保存好,配置

GEMINI_API_KEY

,本地起一个 Postgres 实例,跑起来看效果。

 import argparse  
import asyncio  
import hashlib  
import html  
import json  
import operator  
import os  
import re  
from urllib.parse import urlencode  
from typing import Annotated, Any, TypedDict  

from dotenv import load_dotenv  
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage  
from langchain_core.tools import tool  
from langchain_google_genai import ChatGoogleGenerativeAI  
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver  
from langgraph.graph import END, START, StateGraph  
from langgraph.graph.message import add_messages  
from langgraph.prebuilt import ToolNode  
from psycopg_pool import AsyncConnectionPool  
import httpx  

load_dotenv()  

def _http_get(url: str, timeout: int = 12) -> str:  
    with httpx.Client(timeout=timeout, headers={"User-Agent": "Mozilla/5.0"}) as client:  
        response = client.get(url)  
        response.raise_for_status()  
        return response.text  

def _clean_text_from_html(raw_html: str) -> tuple[str, str]:  
    title_match = re.search(r"<title[^>]*>(.*?)</title>", raw_html, flags=re.IGNORECASE | re.DOTALL)  
    title = html.unescape(title_match.group(1).strip()) if title_match else "Untitled"  
    no_script = re.sub(r"<script[^>]*>.*?</script>", " ", raw_html, flags=re.IGNORECASE | re.DOTALL)  
    no_style = re.sub(r"<style[^>]*>.*?</style>", " ", no_script, flags=re.IGNORECASE | re.DOTALL)  
    text = re.sub(r"<[^>]+>", " ", no_style)  
    text = html.unescape(re.sub(r"\s+", " ", text)).strip()  
    return title, text  

@tool  
def search_web(query: str, max_results: int = 5) -> str:  
    """Search the web via DuckDuckGo Instant Answer API. Returns JSON list."""  
    try:  
        params = urlencode(  
            {  
                "q": query,  
                "format": "json",  
                "no_html": "1",  
                "no_redirect": "1",  
                "skip_disambig": "1",  
            }  
        )  
        payload = _http_get(f"https://api.duckduckgo.com/?{params}")  
        data = json.loads(payload)  

        results: list[dict[str, str]] = []  
        abstract_url = (data.get("AbstractURL") or "").strip()  
        if abstract_url:  
            results.append(  
                {  
                    "url": abstract_url,  
                    "title": (data.get("Heading") or "DuckDuckGo Abstract").strip(),  
                    "snippet": (data.get("AbstractText") or "").strip(),  
                }  
            )  

        def _collect_topics(items: list[dict[str, Any]]) -> None:  
            for item in items:  
                if "FirstURL" in item:  
                    results.append(  
                        {/* Lines 437-440 omitted */}  
                    )  
                elif "Topics" in item and isinstance(item["Topics"], list):  
                    _collect_topics(item["Topics"])  

        related = data.get("RelatedTopics")  
        if isinstance(related, list):  
            _collect_topics(related)  

        deduped: dict[str, dict[str, str]] = {}  
        for item in results:  
            if item["url"]:  
                deduped[item["url"]] = item  

        trimmed = list(deduped.values())[: max(1, max_results)]  
        return json.dumps(trimmed)  
    except Exception as exc:  
        return json.dumps([{"url": "", "title": "search_error", "snippet": str(exc)}])  

@tool  
def fetch_url(url: str) -> str:  
    """Fetch and compress a URL into JSON: {url,title,snippet}."""  
    try:  
        raw_html = _http_get(url)  
        title, text = _clean_text_from_html(raw_html)  
        return json.dumps({"url": url, "title": title, "snippet": text[:900]})  
    except Exception as exc:  
        return json.dumps({"url": url, "title": "fetch_error", "snippet": str(exc)})  

class ResearchState(TypedDict):  
    topic: str  
    messages: Annotated[list[BaseMessage], add_messages]  
    citations: Annotated[list[dict[str, str]], operator.add]  
    seen_urls: Annotated[list[str], operator.add]  
    step_count: int  
    max_steps: int  
    stagnant_turns: int  
    final_brief: str  

def parse_citations_from_tool_messages(messages: list[BaseMessage]) -> tuple[list[dict[str, str]], list[str]]:  
    citations: list[dict[str, str]] = []  
    seen_urls: list[str] = []  
    for message in messages:  
        if getattr(message, "type", "") != "tool":  
            continue  
        content = getattr(message, "content", "")  
        if not isinstance(content, str):  
            continue  
        try:  
            parsed = json.loads(content)  
        except Exception:  
            continue  

        candidates = parsed if isinstance(parsed, list) else [parsed]  
        for item in candidates:  
            if not isinstance(item, dict):  
                continue  
            url = str(item.get("url", "")).strip()  
            if not url:  
                continue  
            seen_urls.append(url)  
            citations.append(  
                {  
                    "url": url,  
                    /* Lines 511-512 omitted */  
                    "snippet": str(item.get("snippet", "")).strip(),  
                }  
            )  
    return citations, seen_urls  

def compact_unique_citations(citations: list[dict[str, str]]) -> list[dict[str, str]]:  
    deduped: dict[str, dict[str, str]] = {}  
    for c in citations:  
        url = c.get("url", "")  
        if not url:  
            continue  
        if url not in deduped:  
            deduped[url] = c  
    return list(deduped.values())  

async def reason_node(state: ResearchState, llm_with_tools: Any) -> dict[str, Any]:  
    system = SystemMessage(  
        content=(  
            "You are a research brief agent. Use tools to gather evidence and cite URLs. "  
            "Prefer search first, then fetch_url for top links. "  
            "When you have enough evidence, return a concise final brief. "  
            "Never fabricate citations."  
        )  
    )  
    response = await llm_with_tools.ainvoke([system] + state["messages"])  
    return {  
        "messages": [response],  
        "step_count": state["step_count"] + 1,  
    }  

def route_after_reason(state: ResearchState) -> str:  
    if state["step_count"] >= state["max_steps"]:  
        return "finalize"  
    last = state["messages"][-1]  
    if isinstance(last, AIMessage) and last.tool_calls:  
        return "tools"  
    return "finalize"  

def sanitize_observation_node(state: ResearchState) -> dict[str, Any]:  
    citations, seen = parse_citations_from_tool_messages(state["messages"][-3:])  
    already_seen = set(state["seen_urls"])  
    new_seen = [u for u in seen if u and u not in already_seen]  
    new_citations = [c for c in citations if c["url"] in set(new_seen)]  
    new_url_count = len(new_seen)  
    stagnant_turns = state["stagnant_turns"] + 1 if new_url_count == 0 else 0  

    return {  
        "citations": new_citations,  
        "seen_urls": new_seen,  
        "stagnant_turns": stagnant_turns,  
    }  

def route_after_sanitize(state: ResearchState) -> str:  
    if state["step_count"] >= state["max_steps"]:  
        return "finalize"  
    if state["stagnant_turns"] >= 3:  
        return "finalize"  
    return "reason"  

async def finalize_node(state: ResearchState, llm: Any) -> dict[str, Any]:  
    citation_block = "\n".join(  
        f"- {c['title']} | {c['url']} | {c['snippet'][:180]}"  
        for c in compact_unique_citations(state["citations"])[:8]  
    )  
    prompt = (  
        f"Topic: {state['topic']}\n\n"  
        "Write a concise research brief in 8-12 bullets.\n"  
        "Each bullet should be evidence-backed where possible.\n"  
        "Then add a short 'Citations' section with URL list only.\n\n"  
        f"Evidence:\n{citation_block if citation_block else '- No citations collected.'}"  
    )  
    response = await llm.ainvoke([HumanMessage(content=prompt)])  
    return {  
        "final_brief": str(response.content),  
        "messages": [AIMessage(content=f"Final brief generated with {len(state['citations'])} citations.")],  
    }  

def build_graph(tools: list[Any], llm: Any):  
    llm_with_tools = llm.bind_tools(tools)  
    tool_node = ToolNode(tools)  

    builder = StateGraph(ResearchState)  

    async def _reason(state: ResearchState) -> dict[str, Any]:  
        return await reason_node(state, llm_with_tools)  

    async def _finalize(state: ResearchState) -> dict[str, Any]:  
        return await finalize_node(state, llm)  

    builder.add_node("reason", _reason)  
    builder.add_node("tools", tool_node)  
    builder.add_node("sanitize", sanitize_observation_node)  
    builder.add_node("finalize", _finalize)  

    builder.add_edge(START, "reason")  
    builder.add_conditional_edges("reason", route_after_reason, {"tools": "tools", "finalize": "finalize"})  
    builder.add_edge("tools", "sanitize")  
    builder.add_conditional_edges("sanitize", route_after_sanitize, {"reason": "reason", "finalize": "finalize"})  
    builder.add_edge("finalize", END)  

    return builder  

def topic_to_thread_id(topic: str) -> str:  
    digest = hashlib.sha1(topic.encode("utf-8")).hexdigest()[:10]  
    return f"research-{digest}"  

async def run(topic: str, max_steps: int) -> None:  
    db_uri = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/postgres")  
    model_name = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")  
    llm = ChatGoogleGenerativeAI(model=model_name, temperature=0.1)  

    tools = [search_web, fetch_url]  
    graph = build_graph(tools=tools, llm=llm)  

    async with AsyncConnectionPool(conninfo=db_uri, max_size=10) as pool:  
        checkpointer = AsyncPostgresSaver(pool)  
        await checkpointer.setup()  

        app = graph.compile(checkpointer=checkpointer)  
        thread_id = topic_to_thread_id(topic)  
        config = {"configurable": {"thread_id": thread_id}}  

        initial: ResearchState = {  
            "topic": topic,  
            "messages": [HumanMessage(content=f"Research this topic: {topic}")],  
            "citations": [],  
            "seen_urls": [],  
            "step_count": 0,  
            "max_steps": max_steps,  
            "stagnant_turns": 0,  
            "final_brief": "",  
        }  

        result = await app.ainvoke(initial, config=config)  
        final_brief = result.get("final_brief", "").strip()  
        citations = compact_unique_citations(result.get("citations", []))  

        print("\n" + "=" * 70)  
        print(f"Thread ID: {thread_id}")  
        print(f"Model: {model_name}")  
        print(f"Tools loaded: {[getattr(t, 'name', str(t)) for t in tools]}")  
        print("=" * 70)  
        print("\nResearch Brief:\n")  
        print(final_brief or "(No final brief generated.)")  
        print("\nCitations:\n")  
        if citations:  
            for idx, c in enumerate(citations, start=1):  
                print(f"{idx}. {c['url']}")  
        else:  
            print("No citations captured.")  

def parse_args() -> argparse.Namespace:  
    parser = argparse.ArgumentParser(description="ReAct research agent (LangGraph + Postgres).")  
    parser.add_argument(  
        "--topic",  
        type=str,  
        default="Practical use cases of ReAct agents in 2026 and common failure modes",  
        help="Research topic to investigate.",  
    )  
    parser.add_argument(  
        "--max-steps",  
        type=int,  
        default=10,  
        help="Maximum ReAct loop steps before forced finalization.",  
    )  
    return parser.parse_args()  

if __name__ == "__main__":  
    args = parse_args()  
     asyncio.run(run(topic=args.topic, max_steps=args.max_steps))

总结

到这一步,一个能跑的 ReAct 循环已经具备:原生 tool calling,state 持久化,脆弱的字符串解析换成了结构化的证据收集。

但成本失控的问题还存在。后续我们会把这个循环锁紧——硬性约束、token 预算、循环检测算法一起上,把这份原型变成可以放到生产环境长期运行的模式。

https://avoid.overfit.cn/post/1f0a889672024b59bb8791df1fc2b5e5

by Anubhav

目录
相关文章
|
2月前
|
存储 设计模式 缓存
为生产级 AI Agent 构建持久化记忆:五阶段流水线与四种设计模式
LLM Agent需持久化记忆以支撑连续对话、用户画像、知识沉淀与崩溃恢复。但满上下文方案成本高、延迟大、易出错。本文提出五阶段流水线(抽取→整合→存储→检索→遗忘)与四种记忆类型(工作/情景/语义/过程记忆),结合结构化状态+向量搜索等设计模式,实现高效、可控、可审计的生产级记忆系统。
636 9
为生产级 AI Agent 构建持久化记忆:五阶段流水线与四种设计模式
|
3月前
|
人工智能 JavaScript API
解放双手!OpenClaw Agent Browser全攻略(阿里云+本地部署+免费API+网页自动化场景落地)
“让AI聊聊天、写代码不难,难的是让它自己打开网页、填表单、查数据”——2026年,无数OpenClaw用户被这个痛点困扰。参考文章直击核心:当AI只能“纸上谈兵”,无法实际操控浏览器,就永远成不了真正的“数字员工”。而Agent Browser技能的出现,彻底打破了这一壁垒——它给OpenClaw装上“上网的手和眼睛”,让AI能像真人一样打开网页、点击按钮、填写表单、提取数据,24小时不间断完成网页自动化任务。
6534 8
|
4月前
|
人工智能 NoSQL Redis
LangGraph 入门:用图结构构建你的第一个多智能体工作流
LangGraph 是面向多智能体系统的图编排框架,以有向状态图替代线性链式调用。通过节点(智能体)、边(条件/静态跳转)和类型化共享状态三者解耦,天然支持分支、循环、并行与汇合;内置检查点、原子状态更新与Reducer机制,保障一致性、可调试性与容错恢复能力。
3263 1
|
2月前
|
数据采集 JSON 自然语言处理
LLM 幻觉的架构级修复:推理参数、RAG、受约束解码与生成后验证
大型语言模型虽能力强,却易“自信撒谎”——即幻觉问题。本文系统拆解五层防御架构:1)推理参数调优(如低temperature+top_p);2)RAG、CoT、结构化输出等架构策略;3)生成后事实/引用/实体四重验证;4)领域微调与置信度校准;5)持续评估监控。强调幻觉不可根除,唯靠多层协同防御。
290 3
LLM 幻觉的架构级修复:推理参数、RAG、受约束解码与生成后验证
|
2月前
|
缓存 小程序 算法
外卖配送小程序开发核心难点:调度系统与订单分发机制解析
外卖配送小程序开发的核心不在前端界面,而在后端两大能力:智能调度系统(决定配送效率)与科学订单分发机制(保障稳定性和骑手体验)。多数项目“能用但跑不动”,症结恰在此——缺乏多约束实时优化、动态评分派单、多单路径规划及高并发架构设计。
|
2月前
|
存储 人工智能 Java
告别 AI 对话 “失忆”!Spring AI 聊天记忆底层原理与全场景落地实战
Spring AI提供优雅的聊天记忆解决方案,彻底解决大模型“失忆”痛点。其分层架构支持内存/MySQL等多存储,通过ChatMemory、ChatMemoryRepository和ChatMemoryAdvisor三大组件,实现会话隔离、消息有序、窗口可控,开箱即用,低侵入、高扩展。
621 13
告别 AI 对话 “失忆”!Spring AI 聊天记忆底层原理与全场景落地实战
|
2月前
|
人工智能 开发者 C++
Claude Code 搞了个UltraPlan:Agent开始上云写代码了!
UltraPlan是Anthropic推出的AI编程新范式:将代码规划“动脑”环节移至云端,终端专注“动手”,实现不卡顿、可协作、灵活执行。支持精准评论、异步运行与多端同步,兼顾效率与隐私选择权。(239字)
294 5
|
2月前
|
人工智能 IDE 开发工具
Zed 编辑器小修小补:让 diff 颜色终于“说人话“了!
本文详解Zed IDE在diff颜色语义化上的精妙改进:新增行用绿色、删除行用红色,真正符合直觉;通过专属token解耦样式与语义,提升主题兼容性与可维护性,并为未来扩展预留接口。小改动,大体验!
157 1
|
2月前
|
Java 大数据 双11
一张图看懂 Java 能干什么——从淘宝下单到双11抢货,背后都是它
本文专为Java零基础小白打造,用通俗比喻讲清Java本质(“万能翻译官”)、跨平台特性及核心优势;解析其在电商、支付等真实场景的应用;破除“Java已死”误区,结合数据说明其持续强势;并给出清晰入门路径与实用学习建议,助你科学起步。
一张图看懂 Java 能干什么——从淘宝下单到双11抢货,背后都是它