news 2026/10/18 15:57:31

AI Agent-Manus 构建经验解读(下):上下文工程与状态机落地实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
AI Agent-Manus 构建经验解读(下):上下文工程与状态机落地实践

1. 为什么 Manus 类 Agent 跑十几步就开始“失忆”

做过多轮任务编排的人大概率遇到过这种场景:Agent 前几步还能准确调用搜索、读文件、写代码,跑到第十五步左右突然开始重复调用同一个工具,或者把最初的任务目标忘得一干二净。你翻看日志会发现,模型并没有报错,工具也正常返回,但决策质量就是断崖式下跌。这不是模型本身变笨了,而是上下文工程没做好。

Manus 团队在公开的构建经验里把这类问题拆得很细:动作空间膨胀导致工具选错、长上下文里目标注意力衰减、错误被清理后模型反复踩同一个坑、少样本示例过于同质化让模型陷入机械模仿。这些问题在多轮任务编排场景里几乎必然出现,区别只在于你提前设计好约束,还是等线上出问题再回头补。

这篇是下篇,聚焦两件能直接落地的事:上下文裁剪与状态机设计。我会给出可复制的上下文裁剪配置、状态机节点定义、LLM 调用参数,以及本地验证步骤。目标很明确——让你能在自己机器上复现一条稳定的任务流,而不是停留在概念层面。适合已经写过基础 Agent 循环、正在被长任务稳定性折磨的开发者。如果你还没搭过最小 Agent,建议先补上工具调用那一环再回来。

核心检索词先明确:AI Agent 的上下文工程与状态机落地,解决的是多轮任务编排中上下文膨胀、工具误选、目标漂移三类问题。下面从环境准备开始,一步步把配置和代码跑通。

2. 用 TaoToken 统一 LLM 调用入口与模型切换

在写状态机之前,得先有一个稳定的 LLM 调用入口。多轮任务编排里,你可能会在不同节点用不同模型:规划节点用推理强的,工具调用节点用响应快的,压缩节点用便宜的长上下文模型。如果每个节点都单独配一套 API Key 和 Base URL,维护成本会很高。

我习惯用 TaoToken 做统一入口,它兼容 OpenAI 风格的接口,切换模型只需要改 model 字段。官网地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 端点是 https://taotoken.net/api ,注意 API 地址不带 UTM 参数。

先拿 Key。进入控制台页面 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,在 API Keys 菜单里创建一个新 Key,复制出来存到环境变量。不要硬编码进代码,后面状态机配置里会引用环境变量名。

模型选择上,规划类节点建议用推理能力强的模型,工具调用密集的节点用指令跟随好的模型。你可以在模型对话页面 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 先手动试几条 prompt,确认模型对 JSON 格式工具调用的遵循程度,再写进配置。

如果你打算长期跑编码类 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 的调用示例。

环境变量这样设:

export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_BASE_URL="https://taotoken.net/api"

验证一下连通性:

curl -s "$TAOTOKEN_BASE_URL/v1/chat/completions" \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-4o-mini", "messages": [{"role": "user", "content": "回复 OK"}], "max_tokens": 10 }'

返回里能看到 choices[0].message.content 就说明通了。这一步别跳过,后面状态机调试时如果报 401,你会分不清是 Key 问题还是代码问题。

3. 可复制的上下文裁剪与状态机配置

这一节是核心。我把配置拆成三块:上下文裁剪策略、状态机节点定义、LLM 调用参数。全部用 JSON 和 TOML 表达,你可以直接存成文件。

先看上下文裁剪配置,存为context_policy.json:

{ "max_context_tokens": 32000, "reserve_for_response": 2048, "compression": { "strategy": "recoverable", "keep_url_as_index": true, "keep_file_path_as_index": true, "drop_raw_web_content": true, "drop_raw_doc_body": true }, "attention_anchor": { "todo_file": "/plan/todo.md", "rewrite_every_steps": 3, "template": "- [ ] {task}\n- [x] {done}" }, "error_retention": { "keep_failed_actions": true, "keep_stack_trace": true, "max_error_entries": 5 }, "few_shot_diversity": { "enabled": true, "template_pool": ["json", "yaml", "plain"], "shuffle_order": true } }

这里几个字段对应 Manus 经验里的关键点。compression.strategy设为 recoverable,意思是网页内容可以从上下文移除,但保留 URL;文档正文可以省略,但保留沙盒里的文件路径。这样上下文长度降下来了,信息还能通过元数据重建。attention_anchor让 Agent 每三步重写一次 todo.md,把全局目标推到上下文末尾,对抗注意力衰减。error_retention保留失败动作和堆栈,让模型从错误里学。few_shot_diversity用不同模板序列化示例,打破模式依赖。

再看状态机节点定义,存为state_machine.toml:

[states.IDLE] description = "用户刚输入,必须文本回复" allowed_tools = [] prefill = "<|im_start|>assistant" mask_tool_tokens = true [states.TOOL_REQUIRED] description = "必须调用工具,不限具体工具" allowed_tools = ["*"] prefill = "<|im_start|>assistant<tool_call>" mask_tool_tokens = false [states.TOOL_SPECIFIED] description = "必须从指定工具组调用" allowed_tools = ["browser_*", "shell_*"] prefill = "<|im_start|>assistant<tool_call>{\"name\": \"browser_" mask_tool_tokens = true [transitions] user_message = "IDLE" tool_result = "TOOL_REQUIRED" plan_ready = "TOOL_SPECIFIED"

状态机的作用是约束动作空间。IDLE 状态下遮蔽所有工具 token,模型只能输出文本;TOOL_SPECIFIED 状态下只允许 browser_ 前缀的工具,其他工具名称的 logits 被压到负无穷。这样既保持了上下文一致性,又动态优化了动作选择。

LLM 调用参数存为llm_params.json:

{ "base_url": "https://taotoken.net/api", "api_key_env": "TAOTOKEN_API_KEY", "models": { "planner": "gpt-4o", "executor": "gpt-4o-mini", "compressor": "gpt-4o-mini" }, "temperature": 0.2, "top_p": 0.9, "max_tokens": 2048, "logit_bias": {}, "response_format": {"type": "json_object"} }

注意base_url用的是不带 UTM 的 API 地址。api_key_env引用环境变量,避免明文。三个模型角色分开配,规划用强模型,执行和压缩用轻量模型,成本可控。

把这三份配置放进项目config/目录,代码里加载即可。下面给一段 Python 加载逻辑:

import json import os import tomllib def load_configs(config_dir="config"): with open(os.path.join(config_dir, "context_policy.json")) as f: context_policy = json.load(f) with open(os.path.join(config_dir, "state_machine.toml"), "rb") as f: state_machine = tomllib.load(f) with open(os.path.join(config_dir, "llm_params.json")) as f: llm_params = json.load(f) llm_params["api_key"] = os.environ[llm_params["api_key_env"]] return context_policy, state_machine, llm_params

到这里配置层就齐了。下一步是把它接进实际的请求循环,验证状态机是否按预期约束模型输出。

4. 本地验证:从请求到成功结果

配置写好了,得跑起来看效果。我写一个最小验证脚本,模拟“查一下今天的新闻”这个任务,观察状态机如何从 IDLE 转到 TOOL_SPECIFIED,以及 logits 遮蔽是否生效。

先定义状态机执行器:

class AgentStateMachine: def __init__(self, config): self.config = config self.current_state = "IDLE" def transition(self, event): transitions = self.config["transitions"] if event in transitions: self.current_state = transitions[event] return self.current_state def get_prefill(self): return self.config["states"][self.current_state]["prefill"] def get_allowed_tools(self): return self.config["states"][self.current_state]["allowed_tools"] def should_mask_tools(self): return self.config["states"][self.current_state]["mask_tool_tokens"]

然后写调用逻辑,把 prefill 拼到 messages 末尾,用 response_format 约束 JSON 输出:

import requests def call_llm(llm_params, messages, prefill=""): model = llm_params["models"]["executor"] payload = { "model": model, "messages": messages, "temperature": llm_params["temperature"], "top_p": llm_params["top_p"], "max_tokens": llm_params["max_tokens"], "response_format": llm_params["response_format"] } if prefill: payload["messages"].append({"role": "assistant", "content": prefill}) resp = requests.post( f"{llm_params['base_url']}/v1/chat/completions", headers={"Authorization": f"Bearer {llm_params['api_key']}"}, json=payload, timeout=60 ) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"]

跑一次完整流程:

context_policy, state_machine_cfg, llm_params = load_configs() sm = AgentStateMachine(state_machine_cfg) messages = [{"role": "user", "content": "查一下今天的新闻"}] sm.transition("user_message") print("当前状态:", sm.current_state) # IDLE # IDLE 状态:遮蔽工具,只允许文本 prefill = sm.get_prefill() result = call_llm(llm_params, messages, prefill) print("IDLE 输出:", result)

预期输出是一段纯文本,比如“好的,我将为您搜索新闻,请稍等”。如果模型试图输出 tool_call,说明遮蔽没生效,检查mask_tool_tokens是否被正确读取。

接着模拟工具结果回来,状态转到 TOOL_SPECIFIED:

sm.transition("tool_result") sm.transition("plan_ready") print("当前状态:", sm.current_state) # TOOL_SPECIFIED print("允许工具:", sm.get_allowed_tools()) # ['browser_*', 'shell_*'] messages.append({"role": "assistant", "content": "好的,我将为您搜索新闻"}) messages.append({"role": "user", "content": "工具结果: 无"}) prefill = sm.get_prefill() result = call_llm(llm_params, messages, prefill) print("TOOL_SPECIFIED 输出:", result)

这次预期输出是{"name": "browser_search", "parameters": {"query": "今日新闻"}}这样的工具调用。prefill 已经锚定了browser_前缀,模型只能补全这个前缀下的工具名。

成功结果长这样:

{ "name": "browser_search", "parameters": {"query": "今日新闻"} }

如果输出里出现了shell_exec或其他非 browser_ 前缀的工具,说明 logits 遮蔽逻辑没接上。遮蔽需要在推理框架层实现,用 Hugging Face 的 LogitsProcessor 或 vLLM 的 logits_processor 参数注入。上面这段验证脚本用的是 API 调用,遮蔽靠 prefill 和 response_format 近似实现;如果你要严格遮蔽,得在本地推理时加 LogitsProcessor。

验证通过后,把 todo.md 的更新逻辑接进去。每三步重写一次:

def update_todo(step, tasks, done): if step % 3 != 0: return lines = [] for t in tasks: lines.append(f"- [ ] {t}") for d in done: lines.append(f"- [x] {d}") with open("/plan/todo.md", "w") as f: f.write("\n".join(lines))

把 todo.md 内容追加到 messages 末尾,让全局目标始终在近期注意力范围内。这一步做完,长任务的稳定性会有明显提升。

5. 常见报错排查:401、local proxy failed、reading choices

跑起来之后大概率会撞几个错。我把踩过的坑列出来,对照排查。

401 Unauthorized。最常见的原因是 Key 没设对或环境变量没加载。先确认echo $TAOTOKEN_API_KEY有输出,再检查代码里是不是用了os.environ读取。如果 Key 是从控制台复制的,注意别带多余空格。还有一种情况是 Base URL 写成了带 UTM 的官网地址,API 调用必须用 https://taotoken.net/api ,不带任何查询参数。

local proxy failed。这个报错通常出现在你本地配了 HTTP 代理,但代理没启动或端口不对。检查HTTP_PROXY和HTTPS_PROXY环境变量,如果不需要代理就 unset 掉。另外确认防火墙没拦请求,用 curl 直接测 API 端点能通再跑代码。

reading choices 报错。典型信息是KeyError: 'choices'或list index out of range。这说明返回体结构和你预期的不一样。先打印完整 response,看是不是返回了 error 字段。常见原因是 model 字段写错,或者 max_tokens 设得太大超过模型上限。还有一种情况是 response_format 设了 json_object 但模型不支持,返回了错误信息。把 response_format 去掉再试。

OAuth 相关报错。如果你用的是 Claude Code 或 Codex 这类工具,可能会遇到 OAuth token 过期。这类工具通常有自己的认证流程,检查配置文件里的 token 是否刷新。如果同时用了 TaoToken 的 API Key 和工具自带的 OAuth,注意别让两套认证互相覆盖。Claude Code 接入时,Base URL 填 https://taotoken.net/api ,Key 填 TaoToken 的 Key,Model ID 填你选的模型名,三件套缺一不可。

工具调用格式违规。模型输出了非 JSON 的工具调用,或者工具名不在允许列表里。先检查 prefill 是否正确拼接,再确认状态机的 allowed_tools 有没有生效。如果用的是 API 调用而非本地推理,logits 遮蔽无法直接注入,只能靠 prefill 和 prompt 约束。这种情况下建议在系统提示里明确工具名前缀规则,并在解析层做校验,遇到违规输出就重试。

上下文超限。报错信息通常是maximum context length exceeded。检查 context_policy.json 里的 max_context_tokens 是否和模型实际窗口匹配。压缩策略要确保 drop_raw_web_content 和 drop_raw_doc_body 都开了,否则长网页会把上下文撑爆。todo.md 本身很短,不用担心它占空间。

排查顺序建议:先 curl 测通 API,再跑最小调用脚本,最后接状态机。每步都打印中间结果,别一次性全接上再调。

6. 把状态机接进你的 Agent 循环

配置和验证都跑通后,最后一步是把它接进真实的 Agent 循环。核心思路是:每轮决策前先过状态机,拿到 prefill 和 allowed_tools,再调 LLM,解析输出后更新状态。

循环骨架大概这样:

def agent_loop(user_input, max_steps=50): context_policy, sm_cfg, llm_params = load_configs() sm = AgentStateMachine(sm_cfg) messages = [{"role": "user", "content": user_input}] sm.transition("user_message") for step in range(max_steps): prefill = sm.get_prefill() output = call_llm(llm_params, messages, prefill) messages.append({"role": "assistant", "content": output}) if sm.current_state == "IDLE": sm.transition("tool_result") continue action = parse_action(output) if action is None: messages.append({"role": "user", "content": "格式错误,请重新输出"}) continue result = execute_tool(action) messages.append({"role": "user", "content": f"工具结果: {result}"}) update_todo(step, tasks, done) sm.transition("tool_result") sm.transition("plan_ready") return messages

几个细节值得注意。parse_action要做严格校验,工具名必须匹配 allowed_tools 里的前缀,参数必须是合法 JSON。校验失败就把错误信息塞回上下文,让模型重试,而不是直接抛异常。update_todo每三步调一次,把 todo.md 内容追加到 messages 末尾。错误结果不要清理,原样保留在上下文里,让模型看到失败轨迹。

模型切换上,规划节点用 planner 模型,执行节点用 executor 模型,压缩节点用 compressor 模型。切换时只改 model 字段,Base URL 和 Key 不变。这样一套入口管三个角色,维护简单。

如果你要跑更复杂的编码 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 里有完整的参数说明,遇到不确定的字段先去查文档。

最后提醒一点:状态机的状态转换要跟业务事件对齐。用户发消息转 IDLE,工具返回转 TOOL_REQUIRED,规划完成转 TOOL_SPECIFIED。事件命名别太抽象,否则后面加状态时容易乱。我试过把事件名写成 step_done 这种模糊词,结果三个状态抢同一个事件,调试了半天。命名具体一点,比如 user_message、tool_result、plan_ready,一眼能看出触发时机。

跑通之后,你可以把 max_steps 设成 50 跑一个真实任务,观察 todo.md 是否每三步更新、错误是否保留、工具调用是否始终在允许前缀内。这三项都符合预期,说明上下文工程和状态机落地到位了。

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

【普通数组】LC 56.合并区间

文章目录前言一、题目1、原题链接2、题目描述二、个人思路整理1、思路分析2、解题代码三、知识风暴前言 本专栏文章为《LeetCode 热题 100》的刷题题解&#xff0c;相关内容如有侵权&#xff0c;立即删除。 一、题目 1、原题链接 56.合并区间 2、题目描述 二、个人思路整理 1…

作者头像 李华
网站建设 2026/10/18 15:49:52

纯JavaScript工作流引擎:从审批流到自定义节点

简介&#xff1a;这是一份面向前端与全栈开发者的 JavaScript 工作流与审批流实现资源&#xff0c;适合需要理解流程引擎原理、搭建审批类业务系统的初中级开发者参考。资源围绕工作流概念、状态机驱动、任务分配与调度、审批界面交互、数据持久化及异常回退等核心环节展开&…

作者头像 李华
网站建设 2026/10/18 15:49:09

信息化教学设计高端模板:从框架到视觉的实战指南

简介&#xff1a;信息化教学设计高端模板是一份面向教师、教研员及师范生的演示文稿&#xff0c;围绕教学分析、教学设计、教学过程、教学反思四个环节搭建完整备课框架。模板将课程结构、教学内容、教学目标、教学重点、学情分析与教学难点系统梳理&#xff0c;帮助使用者在课…

作者头像 李华