不用框架手写 AI Agent:ReAct 推理链和上下文管理的工程实践

最近在做一个不依赖 LangChain 的 Agent 项目,从 while 循环写到上下文管理,过程中踩了不少坑。这篇把 Agent 循环、ReAct 模式、上下文工程三块的实现细节和实际问题记录下来。


Agent 循环的最小实现

抛开框架,Agent 的核心就是一个 while 循环。以下是实际跑通的完整代码(基于 DeepSeek API,兼容 OpenAI SDK):

python 复制代码
import json
from openai import OpenAI

client = OpenAI(
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com"
)

# 工具定义 --- 告诉模型有哪些工具可用、参数是什么
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "获取指定城市的当前天气",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {"type": "string", "description": "城市名称"}
                },
                "required": ["city"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "calculate",
            "description": "计算数学表达式",
            "parameters": {
                "type": "object",
                "properties": {
                    "expression": {"type": "string", "description": "数学表达式"}
                },
                "required": ["expression"]
            }
        }
    },
]

# 工具的实际实现
def get_weather(city: str) -> str:
    data = {
        "北京": "晴天,28°C,湿度 45%",
        "上海": "多云,31°C,湿度 72%",
    }
    return data.get(city, f"暂无{city}的天气数据")

def calculate(expression: str) -> str:
    try:
        allowed = set("0123456789+-*/.() ")
        if all(c in allowed for c in expression):
            return f"{expression} = {eval(expression)}"
        return "不支持的表达式"
    except Exception as e:
        return f"计算错误: {str(e)}"

tool_functions = {
    "get_weather": get_weather,
    "calculate": calculate,
}

# Agent 主循环
def run_agent(user_message: str, max_steps: int = 5):
    messages = [
        {"role": "system", "content": "你是一个有帮助的助手。你可以使用工具来获取信息。"},
        {"role": "user", "content": user_message}
    ]

    for step in range(max_steps):
        response = client.chat.completions.create(
            model="deepseek-chat",
            messages=messages,
            tools=tools,
            temperature=0
        )
        assistant_message = response.choices[0].message

        if assistant_message.tool_calls:
            # 模型决定调工具 --- 把 assistant 消息存入历史
            messages.append(assistant_message)

            for tool_call in assistant_message.tool_calls:
                func_name = tool_call.function.name
                func_args = json.loads(tool_call.function.arguments)

                # 执行工具
                result = tool_functions[func_name](**func_args)

                # 把工具结果加入对话(必须带 tool_call_id 对应回去)
                messages.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": result
                })
        else:
            # 模型直接回答,循环结束
            return assistant_message.content

    return "达到最大步数限制"

模型看完 messages 决定要不要调工具,调了就把结果喂回去继续循环,不调就输出答案结束。max_steps 做防护栏防止死循环。

但跑起来之后,几个工程问题很快就冒出来了。

一次工具调用在 messages 里是两条消息

调试的时候发现 messages 条数总对不上,原因是一次工具调用会产生两条消息:模型说"我要调 get_weather"是一条 assistant(带 tool_calls 字段),工具返回"晴天 28°C"是一条 tool。API 要求这两条必须配对出现,漏了 assistant 那条会直接报错。

ChatCompletionMessage 不是 dict

这个问题卡了一会。response.choices[0].message 返回的是 Pydantic 对象,messages.append(msg) 存进去后,后面任何用 msg["role"] 访问的代码都会炸:

python 复制代码
msg["role"]  # TypeError: 'ChatCompletionMessage' object is not subscriptable

解法是用 Pydantic 自带的 model_dump() 转成 dict。但这个问题不只出现在一个地方 --- token 估算函数、上下文管理器、任何遍历 messages 数组的逻辑都会中招。最后在所有入口统一加了兼容:

python 复制代码
if not isinstance(msg, dict):
    msg = msg.model_dump()

ReAct:给推理过程加可观测性

plain loop 跑单步工具调用没问题,但遇到多步推理任务("北京和上海哪个更热,差多少度"),模型的决策过程是黑盒 --- 你只看到它调了什么工具,不知道它是怎么推理的。出了错也没法定位是哪一步想歪了。

ReAct 的做法是让模型在每次调工具前先在 content 里输出思考过程。实现上只需要在 system prompt 里加引导,DeepSeek 支持同时返回 content(思考)和 tool_calls(工具调用):

python 复制代码
FC_REACT_SYSTEM = """你是一个 ReAct Agent。你通过交替进行思考和行动来回答问题。

核心规则:
1. 在每次调用工具之前,你必须在 content 中先输出你的思考过程,格式为 "[Thought] ...你的推理..."
2. 思考要包含:当前掌握了什么信息、还缺什么、下一步为什么要调这个工具
3. 收到工具结果后,继续思考再决定下一步
4. 当信息足够回答时,在 content 中以 "[Thought] ...总结推理..." 开头,然后给出最终答案"""

def react_agent_fc(user_question: str, max_steps: int = 8) -> str:
    messages = [
        {"role": "system", "content": FC_REACT_SYSTEM},
        {"role": "user", "content": user_question}
    ]

    for step in range(max_steps):
        response = client.chat.completions.create(
            model="deepseek-chat",
            messages=messages,
            tools=FC_TOOLS,
            temperature=0,
        )
        msg = response.choices[0].message

        # 显示思考过程(content 字段)--- 这是 ReAct 的核心价值
        if msg.content:
            print(f"💭 {msg.content}")

        if msg.tool_calls:
            messages.append(msg)
            for tc in msg.tool_calls:
                func_name = tc.function.name
                func_args = json.loads(tc.function.arguments)
                result = TOOL_FUNCTIONS[func_name](**func_args)

                print(f"  🔧 Action: {func_name}({func_args})")
                print(f"  📋 Observation: {result}")

                messages.append({
                    "role": "tool",
                    "tool_call_id": tc.id,
                    "content": result
                })
        else:
            answer = msg.content or "(空回答)"
            print(f"\n✅ 最终答案: {answer}")
            return answer

    return "无法在有限步骤内完成"

实际运行效果:

css 复制代码
Step 1:
💭 [Thought] 用户问北京和上海哪个更热,需要先查两个城市的天气。先查北京。
🔧 Action: get_weather({"city": "北京"})
📋 Observation: 晴天,28°C,湿度 45%

Step 2:
💭 [Thought] 北京 28°C。现在查上海。
🔧 Action: get_weather({"city": "上海"})
📋 Observation: 多云,31°C,湿度 72%

Step 3:
💭 [Thought] 上海 31°C - 北京 28°C = 3°C,上海更热。
✅ 上海更热,比北京高 3°C。

有了这个,出错时一眼就能看到模型在哪一步推理出了问题。生产环境把这些 trace 写进日志,调试效率和黑盒完全是两回事。

并行调用的取舍

function calling 模式下模型可能一次返回多个 tool_calls --- 比如一口气查四个城市天气。这比逐步推理快(少了三次 LLM 调用的网络延迟),但思考粒度更粗。

问题出在有依赖关系的场景:如果任务是"查天气拿到温度,再用温度做计算",模型还没看到天气结果就想把 calculate 一起调了,参数只能靠猜。

处理方式:工具之间没有数据依赖就放它并行,有依赖就通过 prompt 引导串行执行。


上下文工程:messages 撑爆之前你得管住它

Agent 跑了十几轮之后,messages 数组的 token 量增长得比想象的快。拿模拟数据跑了 15 轮,53 条消息 1520 tokens,看着不多。但实际项目里一个搜索工具返回 2000 token,跑 10 步就是 20,000 token 光给工具结果了。DeepSeek context window 64K,预算比看上去紧。

而且不只是撑爆的问题:

  • 越长越贵(input token 也计费)
  • 越长越慢(自注意力 O(n²))
  • 越长质量越差(Lost in the Middle --- 模型对中间位置内容关注度最低)

Token 预算分配

把 context window 当内存来管,每类内容定预算:

makefile 复制代码
总可用: 60,000 tokens(64K 减掉 4K 留给输出)
├── System prompt:    ~1,000(固定)
├── 对话摘要:          ~2,000(上限)
├── 最近对话:         ~40,000(弹性)
└── 工具结果:         ~17,000(弹性)

接近阈值时触发裁剪。工具结果是膨胀的头号原因,需要重点控制。

混合策略的实现

试了滑动窗口(只保留最近 N 条,早期信息直接丢)和摘要压缩(LLM 压缩旧消息),最终用的是混合策略:system 永远保留,旧消息做摘要,最近 N 条原样保留,超长工具结果截断。封装成 ContextManager

python 复制代码
def _msg_to_dict(msg) -> dict:
    """统一把 ChatCompletionMessage 对象和 dict 转成 dict"""
    if isinstance(msg, dict):
        return msg
    d = {"role": msg.role, "content": msg.content}
    if hasattr(msg, "tool_calls") and msg.tool_calls:
        d["tool_calls"] = [
            {
                "id": tc.id,
                "type": tc.type,
                "function": {
                    "name": tc.function.name,
                    "arguments": tc.function.arguments,
                }
            }
            for tc in msg.tool_calls
        ]
    if hasattr(msg, "tool_call_id") and msg.tool_call_id:
        d["tool_call_id"] = msg.tool_call_id
    return d


class ContextManager:
    def __init__(
        self,
        client: OpenAI,
        model: str = "deepseek-chat",
        max_context_tokens: int = 60000,
        keep_recent: int = 10,
        tool_result_max_chars: int = 500,
    ):
        self.client = client
        self.model = model
        self.max_context_tokens = max_context_tokens
        self.keep_recent = keep_recent
        self.tool_result_max_chars = tool_result_max_chars
        self.summary = None

    def truncate_tool_result(self, content: str) -> str:
        if len(content) <= self.tool_result_max_chars:
            return content
        return content[:self.tool_result_max_chars] + f"\n...[截断,原文 {len(content)} 字符]"

    def prepare(self, messages: list) -> list:
        # Step 0: 统一转 dict(兼容 ChatCompletionMessage)
        messages = [_msg_to_dict(m) for m in messages]

        # Step 1: 截断过长的工具结果
        processed = []
        for msg in messages:
            if msg["role"] == "tool" or (
                msg["role"] == "user" and
                msg.get("content", "").startswith("Observation:")
            ):
                msg = {**msg, "content": self.truncate_tool_result(msg.get("content", ""))}
            processed.append(msg)

        # Step 2: 检查 token 预算
        current_tokens = estimate_messages_tokens(processed)
        if current_tokens <= self.max_context_tokens:
            return processed  # 没超预算,不用裁

        # Step 3: 分离 system 和非 system
        system_msgs = [m for m in processed if m["role"] == "system"]
        non_system = [m for m in processed if m["role"] != "system"]

        # Step 4: 保留最近 N 条,旧的压缩成摘要
        recent = non_system[-self.keep_recent:]
        old = non_system[:-self.keep_recent]

        if old:
            old_text = "\n".join(
                f"{m['role']}: {(m.get('content', '') or '(tool call)')[:200]}"
                for m in old
            )
            summary_resp = self.client.chat.completions.create(
                model=self.model,
                messages=[
                    {"role": "system", "content": "把以下对话历史压缩成简洁摘要,保留关键事实和数据。不超过 200 字。"},
                    {"role": "user", "content": old_text}
                ],
                temperature=0,
                max_tokens=400,
            )
            self.summary = summary_resp.choices[0].message.content

        # Step 5: 组装
        result = system_msgs[:]
        if self.summary:
            result.append({
                "role": "user",
                "content": f"[之前的对话摘要] {self.summary}"
            })
        result.extend(recent)
        return result

Agent 循环里接入也就两行:

python 复制代码
ctx_mgr = ContextManager(client=client, max_context_tokens=60000)

for question in questions:
    full_messages.append({"role": "user", "content": question})
    for step in range(5):
        # 每次调 API 前过一遍 prepare
        api_messages = ctx_mgr.prepare(full_messages)
        response = client.chat.completions.create(
            model="deepseek-chat",
            messages=api_messages,
            tools=TOOLS_SPEC,
        )
        # ... 后续逻辑不变

检验裁剪质量的办法:聊了一堆之后问"刚才说的那个数据是多少"。摘要做得好能答出来,滑动窗口砍了就答不出来。

工具结果截断的问题

直接按字符数截断不够靠谱 --- 关键数据可能在后面。更好的做法是在工具端控制返回量:搜索接口加 limit,数据库查询加过滤条件。上下文管理层做截断只是兜底,不能当主要手段。


小结

不用框架裸写 Agent 之后对几件事有了更具体的认知:Agent 的复杂度不在 LLM 调用本身,在 messages 数组的生命周期管理上 --- 类型兼容、token 预算、裁剪策略、工具结果的膨胀控制。这些是框架帮你挡住的东西,也是出了问题之后需要你自己定位的东西。

相关推荐
明月_清风1 小时前
发现一个超系统的 AI Agent 学习地图 —— Agent Atlas 推荐
人工智能·后端·agent
百度Geek说2 小时前
商业客户端Harness资产管理与应用实践
人工智能
MacroZheng2 小时前
同事问我:"Claude Code经常失忆,不怕它把项目搞炸?",我:"怕,三个Markdown文件给它装个永不丢失的外置大脑!"
java·人工智能·后端
小酒星小杜2 小时前
一个人,也能做出有人设、能追更、可商业化的漫画内容 IP
人工智能·产品·设计
字节跳动视频云技术团队2 小时前
从出片到出海:AI MediaKit 重塑短剧全链路生产力
人工智能·音视频开发
一点一木2 小时前
憋了7周没动静,OpenClaw 2.0带着16000个PR杀回来了
人工智能·github
anyup2 小时前
DeepSeek Harness 从零上手:从认识到写出第一个插件
人工智能·openai·deepseek
学习星球3 小时前
AI Agent 成本工程实战:从 OpenAI Codex 的 8 个“烧 Token“Bug 学起
人工智能·设计模式·微信小程序
一点一木5 小时前
🚀 2026 年 8 月 GitHub 十大热门项目排行榜 🔥
人工智能·github