最近在做一个不依赖 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 预算、裁剪策略、工具结果的膨胀控制。这些是框架帮你挡住的东西,也是出了问题之后需要你自己定位的东西。