news 2026/9/28 18:25:20

从零开始构建Agent(三):用langgraph手搓一个简略版“Manus”,实现文档分析等小功能

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零开始构建Agent(三):用langgraph手搓一个简略版“Manus”,实现文档分析等小功能

1. 为什么我要用 langgraph 手搓一个简略版 Manus

Manus 这类通用 Agent 最吸引人的地方,是它能自己拆任务、自己调工具、自己写文件、自己出报告。但真到自己动手时,很多人会卡在第一步:状态怎么在节点之间传、工具怎么绑定、循环什么时候退出。langgraph 正好解决这个问题,它把 Agent 拆成一张状态图,每个节点只负责一件事,边和条件跳转由框架管。

这篇要落地的场景很具体:给一个文档,让 Agent 自动生成分析计划、逐步执行、动态更新计划、最后输出一份报告。适合已经写过简单 LLM 调用、想进一步理解 Agent 编排的读者。我会给出可复制的节点骨架、状态定义、TaoToken 统一 Key 的 config.toml 示例,以及跑通验证的完整步骤。整套代码不依赖复杂框架,核心就是 langgraph + langchain + 几个自定义 tool。

先明确一点:这不是要复刻 Manus 的全部能力,而是抓住它最核心的四个动作——规划、执行、更新、报告。把这四个节点跑通,你就有了一个最小可用的 Agent 骨架,后面加工具、换模型、接业务都是在这个骨架上长出来的。

2. TaoToken 前置:统一 Key 与 config.toml 配置

在写节点之前,先把模型接入这层处理干净。Agent 会频繁调用大模型,如果 Key 散落在代码里,后面换模型、调参数会很痛苦。我习惯用一个 config.toml 统一管理,配合 TaoToken 的兼容接口,OpenAI SDK 和 langchain 都能直接读。

TaoToken 的 API 地址是 https://taotoken.net/api,它兼容 OpenAI 的请求格式,所以 langchain 里的 ChatOpenAI 只要改 base_url 和 api_key 就能用。先去控制台创建一个 Key,地址在 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,创建完在 API Keys 页面复制,页面是 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。

config.toml 这样写:

[llm] base_url = "https://taotoken.net/api" api_key = "sk-你的Key" model = "gpt-4o-mini" temperature = 0.2 max_tokens = 4096 [agent] work_dir = "./agent/files" font_path = "./agent/simsun.ttf" max_step_retry = 3

读取配置用一个简单函数,避免硬编码:

import tomllib from langchain_openai import ChatOpenAI def load_config(path="config.toml"): with open(path, "rb") as f: return tomllib.load(f) cfg = load_config() llm = ChatOpenAI( base_url=cfg["llm"]["base_url"], api_key=cfg["llm"]["api_key"], model=cfg["llm"]["model"], temperature=cfg["llm"]["temperature"], )

这里有个细节:base_url 结尾不要带/v1,TaoToken 的兼容层已经处理了路径。如果你用的是其他 SDK,把 base_url 填成https://taotoken.net/api即可。模型名按你实际开通的填,temperature 建议 0.2 左右,Agent 任务需要稳定输出,太发散会导致 JSON 解析失败。

注意:config.toml 不要提交到公开仓库,本地用 .gitignore 排除,或者改用环境变量注入。

3. 可复制的 langgraph 节点与状态图骨架

3.1 定义 State 和工具

State 是整个图的共享内存,节点通过读写它来传递信息。这里用 TypedDict 定义,包含用户消息、计划、观察记录、最终报告。

from typing import TypedDict, Annotated from langgraph.graph.message import add_messages class State(TypedDict): user_message: str plan: dict messages: Annotated[list, add_messages] observations: Annotated[list, add_messages] final_report: str

工具层定义三个基础能力:创建文件、替换文本、执行 shell。这三个就够跑通文档分析。

from langchain_core.tools import tool import os, subprocess @tool def create_file(file_name: str, file_contents: str) -> dict: """在工作区创建文件并写入内容。""" try: path = os.path.join(os.getcwd(), file_name) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: f.write(file_contents) return {"message": f"created {path}"} except Exception as e: return {"error": str(e)} @tool def str_replace(file_name: str, old_str: str, new_str: str) -> dict: """替换文件中第一处匹配文本。""" try: path = os.path.join(os.getcwd(), file_name) with open(path, "r", encoding="utf-8") as f: content = f.read() content = content.replace(old_str, new_str, 1) with open(path, "w", encoding="utf-8") as f: f.write(content) return {"message": f"replaced in {path}"} except Exception as e: return {"error": str(e)} @tool def shell_exec(command: str) -> dict: """执行 shell 命令并返回 stdout/stderr。""" try: r = subprocess.run(command, shell=True, cwd=os.getcwd(), capture_output=True, text=True, check=False) return {"stdout": r.stdout, "stderr": r.stderr} except Exception as e: return {"error": str(e)}

3.2 四个核心节点

规划节点负责把用户输入变成结构化计划。关键是让模型输出严格 JSON,解析失败要有兜底。

import json from langchain_core.messages import SystemMessage, HumanMessage, AIMessage from langgraph.types import Command PLAN_SYSTEM = """你是具备自主规划能力的智能体。默认工作语言中文。 输出必须是严格 JSON,字段:thought(str)、goal(str)、steps(list)。 每个 step 含 title、description、status(pending/completed)。 任务不可行时 steps 返回空数组。""" PLAN_CREATE = """根据用户消息生成计划。 用户消息:{user_message} 只输出 JSON,不要其他内容。""" def extract_json(text: str) -> str: start = text.find("{") end = text.rfind("}") return text[start:end+1] if start != -1 else "{}" def create_planner_node(state: State): msgs = [ SystemMessage(content=PLAN_SYSTEM), HumanMessage(content=PLAN_CREATE.format(user_message=state["user_message"])), ] resp = llm.invoke(msgs) plan = json.loads(extract_json(resp.content)) return Command(goto="execute", update={ "plan": plan, "messages": [AIMessage(content=json.dumps(plan, ensure_ascii=False))], })

执行节点是核心,它找到第一个 pending 步骤,绑定工具循环调用,直到模型不再请求工具。

from langchain_core.messages import ToolMessage EXEC_SYSTEM = """你是具备自主能力的 AI 智能体,擅长数据处理、分析与可视化。 每次只选择一个工具调用,工具失败要换参数重试,直到任务完成。 文件读写用文件工具,代码先保存为文件再执行。""" EXEC_PROMPT = """根据用户消息和当前步骤,选择最合适的工具。 用户消息:{user_message} 当前步骤:{step}""" def execute_node(state: State): plan = state["plan"] steps = plan["steps"] current = None idx = 0 for i, s in enumerate(steps): if s["status"] == "pending": current, idx = s, i break if current is None: return Command(goto="report") msgs = state.get("observations", []) + [ SystemMessage(content=EXEC_SYSTEM), HumanMessage(content=EXEC_PROMPT.format( user_message=state["user_message"], step=current["description"])), ] llm_tools = llm.bind_tools([create_file, str_replace, shell_exec]) new_msgs = [] tool_map = {"create_file": create_file, "str_replace": str_replace, "shell_exec": shell_exec} while True: resp = llm_tools.invoke(msgs) msgs.append(resp) new_msgs.append(resp) if not resp.tool_calls: break for tc in resp.tool_calls: result = tool_map[tc["name"]].invoke(tc["args"]) tm = ToolMessage(content=str(result), tool_call_id=tc["id"]) msgs.append(tm) new_msgs.append(tm) return Command(goto="update_planner", update={ "plan": plan, "messages": new_msgs, "observations": new_msgs, })

更新节点根据执行结果调整剩余步骤,注意它只改未完成的部分。

UPDATE_PROMPT = """根据上下文更新计划,不要更改 goal。 只重新规划未完成步骤,已完成步骤保持不变。 输出与输入格式一致的 JSON。 Plan: {plan} Goal: {goal}""" def update_planner_node(state: State): plan = state["plan"] msgs = state["messages"] + [ SystemMessage(content=PLAN_SYSTEM), HumanMessage(content=UPDATE_PROMPT.format(plan=plan, goal=plan["goal"])), ] for _ in range(3): try: resp = llm.invoke(msgs) new_plan = json.loads(extract_json(resp.content)) return Command(goto="execute", update={"plan": new_plan}) except Exception as e: msgs.append(HumanMessage(content=f"JSON 格式错误:{e},请重新输出")) return Command(goto="execute", update={"plan": plan})

报告节点汇总所有观察记录,生成最终文件。

REPORT_SYSTEM = """你是报告生成专家,根据已有上下文生成分析报告。 报告包含分析背景、数据概述、可视化、结论建议。 图表插入分析过程,不单独列出。以文件形式输出。""" def report_node(state: State): msgs = state.get("observations", []) + [SystemMessage(content=REPORT_SYSTEM)] llm_tools = llm.bind_tools([create_file, shell_exec]) tool_map = {"create_file": create_file, "shell_exec": shell_exec} while True: resp = llm_tools.invoke(msgs) msgs.append(resp) if not resp.tool_calls: break for tc in resp.tool_calls: result = tool_map[tc["name"]].invoke(tc["args"]) msgs.append(ToolMessage(content=str(result), tool_call_id=tc["id"])) return {"final_report": msgs[-1].content}

3.3 组装状态图

因为节点用 Command 直接指定跳转,中间不需要显式加边,但 START 和 END 必须连。

from langgraph.graph import StateGraph, START, END from langgraph.checkpoint.memory import MemorySaver def build_graph(): builder = StateGraph(State) builder.add_node("create_planner", create_planner_node) builder.add_node("execute", execute_node) builder.add_node("update_planner", update_planner_node) builder.add_node("report", report_node) builder.add_edge(START, "create_planner") builder.add_edge("report", END) return builder.compile(checkpointer=MemorySaver()) graph = build_graph()

4. 验证请求与成功结果

跑之前先确认工作目录存在,把要分析的文档放进去。下面用一个 docx 做示例。

import uuid config = {"configurable": {"thread_id": str(uuid.uuid4())}} result = graph.invoke( {"user_message": "分析当前目录下的 计算机视觉.docx,生成一份简单分析报告"}, config=config, ) print(result["final_report"])

正常跑通时,你会看到控制台依次打印规划、执行、更新、报告四个阶段。执行阶段会调用 shell_exec 跑 Python 读取 docx,调用 create_file 保存中间结果。最终在./agent/files下生成报告文件。

如果只想快速验证模型连通性,可以先用模型对话页面发一条消息,地址是 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite ,确认 Key 和模型名没问题,再跑完整图。

验证成功的标志有三个:一是 create_planner 返回的 plan 里 steps 非空;二是 execute 阶段日志里出现 tool_name 和 tool_result;三是 report 节点结束后 final_report 有内容且文件真实存在。

5. 本篇常见错误排查

JSON 解析失败:模型输出带了 markdown 代码块或多余说明。解决方法是 extract_json 里做首尾大括号截取,同时在 prompt 里强调“只输出 JSON”。如果还不行,把 temperature 降到 0。

工具调用死循环:模型反复调用同一个工具但任务没进展。给 while 循环加最大轮次限制,比如 10 次,超过就强制退出并记录。生产环境一定要加这个保护。

文件路径找不到:create_file 里用了相对路径,但 cwd 不是预期目录。统一用 os.getcwd() 拼接,并在启动时打印一次工作目录确认。

模型名或 base_url 报 404:检查 config.toml 里 base_url 是否误加了/v1,TaoToken 的地址是 https://taotoken.net/api ,不要多写路径。模型名要和开通的一致。

Command 跳转不生效:确认节点返回的是 Command 对象而不是普通 dict,且 goto 的目标节点名和 add_node 注册的名字完全一致。

报告节点没有输出文件:模型可能只生成了文本没调工具。在 REPORT_SYSTEM 里明确要求“必须调用 create_file 保存报告”,并在循环结束后检查文件是否存在。

6. 继续扩展与接入建议

这套骨架跑通后,扩展方向很清晰。一是加工具,比如接数据库查询、接图表生成、接搜索;二是把单一模型拆成多模型,规划用强模型、执行用快模型,能明显降延迟;三是把 checkpointer 从内存换成持久化存储,支持断点续跑。

如果你打算长期跑编码类或 Agent 类任务,可以了解下 Coding Plan,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ,它更适合高频调用的场景。接入细节和参数说明在文档里,地址是 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,遇到 SDK 兼容问题可以先翻这里。

最后提醒一句:Agent 的能力上限取决于你给它的工具边界。工具越明确、返回值越结构化,模型越不容易跑偏。先把这篇的四个节点和三个工具跑稳,再往上叠功能,比一上来就堆一堆工具要靠谱得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 18:25:17

STM32 上跑 MQTT 怎么选?嵌入式 MQTT 客户端 C 语言实现对比

STM32 上跑 MQTT 怎么选?嵌入式 MQTT 客户端 C 语言实现对比做嵌入式设备联网,STM32 上跑 MQTT 是绕不开的话题。LwIP 开源 MQTT 客户端自己写,还是直接用现成 SDK?本文对比主流选择,给出 STM32F4 实测结论。建议 CSD…

作者头像 李华
网站建设 2026/9/28 18:24:45

智谱 Z Code 配置 TaoToken:Claude Code、Codex、Gemini 统一 Key 接入指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/28 18:24:25

UVa 930 Polynomial Roots

题目描述 给定一个 nnn 次多项式 P(x)anxnan−1xn−1…a1xa0P(x) a_{n}x^{n} a_{n - 1}x^{n - 1} \ldots a_{1}x a_{0}P(x)an​xnan−1​xn−1…a1​xa0​ 的全部 n1n 1n1 个系数,以及该多项式的 n−2n - 2n−2 个实根,要求计算出剩下的两个实根。…

作者头像 李华
网站建设 2026/9/28 18:23:11

page p4d_none/pgd_none/pud_none/pmd_none/pte_none

p4d_none 是 Linux 页表操作中用于判断 P4D(Page Level-4 Directory,第4级页目录)条目是否为空(不存在)的辅助函数。它的存在与 5 级页表支持紧密相关。核心作用:判断页表层级是否“消失”了在 Linux 的通用…

作者头像 李华