Agent项目跑起来容易,Debug起来要命。前阵子我在一个基于LangChain的Agent项目里补日志工具和路径工具,发现80%的排查时间都花在“不知道Agent当时在想什么”上——它到底选了哪个工具、参数传了什么、检索结果是什么,全得靠猜。这篇内容对应我最近在跟的黑马大模型RAG与Agent智能体实战教程第47节,把LangChain Agent项目里日志工具与路径工具的开发思路完整梳理一遍。如果你已经会写Tool、能跑通一个最简单的AgentExecutor,但一遇到多层工具调用就抓瞎,那这部分就是为你准备的。RAG项目同样适用,检索器的调用过程也能被日志工具完整捕获。
1. 普通日志HOLD不住Agent:为什么必须单独做一套记录机制
1.1 ReAct循环让日志从“打印结果”变成“还原决策”
先说说我为什么放着现成的logging不用,非要自己写一套。普通应用的日志非常好写:请求进来,处理,返回,一行log就能说明白。但Agent完全不是这个套路,它内部跑的是ReAct循环:思考 → 决定调用某个工具 → 拿到观察结果 → 再思考。一个看起来很简单的问题,可能触发两三轮大模型调用,加上五六个工具调用,而且每一次工具返回的结果都会影响下一步的决策。
这种循环结构带来的最大麻烦是:你没法用“打印最终结果”来复盘问题。比如Agent最后说“文件已处理完毕”,但文件内容全是乱的。你不看中间过程,根本不知道是读取工具选错了文件,是大模型总结的时候理解偏了,还是工具返回的结果本来就被截断了。我实测过一个文档处理Agent,它在一次任务里连续读了三次同一个文件。如果日志里没有记录tool_input,我压根发现不了这个重复读取的浪费。
用生活化的类比就是:普通程序是食堂打饭,一条队伍走到底,你只要看最后一个人端了什么菜就行;Agent是在商场里拿着地图反复逛,每个路口都可能拐错,你必须把它的行走轨迹完整画出来。
1.2 日志工具与路径工具的分工:一个记账,一个立规矩
这个标题里把日志工具和路径工具放在一起,不是巧合,它们其实是Agent项目工程化的一体两面。
日志工具解决的是“可观测性”:把Agent的决策过程、工具调用、参数输入、返回结果全部记录下来,让你能在事后像回放录像一样还原现场。路径工具解决的是“行为边界”:让Agent只能访问你允许它访问的资源,比如指定工作目录,不能满盘乱翻。
我把这两件事比作记账和立规矩。日志是记账的,它老老实实记录Agent每一笔花销、每一个动作;路径是立规矩的,它提前划定红线,不允许Agent踏出你指定的范围。两者缺一个都不行。只有日志没有路径,你确实能知道Agent读了“/etc/passwd”,但它已经读了;只有路径没有日志,你会看到一个“权限错误”,但完全不知道Agent为什么想去读那个文件,也没有办法判断它是不是真的理解规则还是纯粹瞎猜。
1.3 这节内容的适用边界与前置要求
这一节的核心在Agent工具的日志与路径管控,不是RAG本身的检索链路。但如果你正在做Agent + RAG项目,日志工具的retriever相关钩子可以直接覆盖检索器调用过程,后面第6章我会单独提。
前置要求并不高:你会用LangChain创建一个自定义Tool,能通过AgentExecutor或类似方式跑通一个Agent就行。接下来要讲的代码基于langchain_core的BaseCallbackHandler接口,这是LangChain历次版本迭代里相对稳定的抽象层,即使你用的不是最新版本,核心思路也完全一致。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. LangChain回调是所有日志插件的底层地基
2.1 回调机制的工作原理
LangChain在底层把所有执行过程都拆成“事件”。大模型开始调用是一个事件、调用结束是一个事件、工具开始执行是一个事件、工具返回又是一个事件。这些事件会广播给所有挂载的监听器,监听器负责做自己的事,比如记录日志、统计耗时、上报指标。
这个机制的好处在于完全非侵入。你不需要去修改大模型调用代码,也不需要给每个工具函数塞日志代码,只需要写一个继承自BaseCallbackHandler的类,然后把它塞到Agent的执行配置里。它就像手术室里监控生命体征的仪器,只在旁边看,不动手术刀。
我一开始尝试的是给每个工具函数手动加print,效果是崩的。工具一多,打印内容混在一起,根本分不清哪一行属于哪个调用链。后来换成回调方案,所有信息自动带上了run_id和parent_run_id,父子调用关系一目了然。
2.2 几个必须掌握的钩子方法
LangChain回调机制里的钩子方法不少,但对Agent日志来说,核心就是下面这几个。我整理成一张表,你照着实现就行。
| 钩子方法 | 触发时机 | 关键参数 |
|---|---|---|
| on_chain_start | 整条链(如AgentExecutor)开始执行 | inputs |
| on_chain_end | 整条链执行结束 | outputs |
| on_llm_start | 大模型开始生成 | prompts |
| on_llm_end | 大模型生成结束 | response |
| on_tool_start | 工具开始执行 | tool名称、input_str |
| on_tool_end | 工具执行结束 | output |
| on_retriever_start | 检索器开始查询 | query |
| on_retriever_end | 检索器返回文档 | documents |
| on_agent_action | Agent解析出下一步动作 | AgentAction |
| on_agent_finish | Agent输出最终答案 | AgentFinish |
其中最常用的是on_llm_start、on_tool_start、on_tool_end、on_agent_action这几个。on_agent_action特别有用,因为它能拿到Agent每轮循环里解析出来的结构化动作,包括选择了哪个工具、填了什么参数。这比从大模型生成的原始文本里硬抠JSON要可靠得多。
2.3 为什么选回调而不是装饰器或中间件
有朋友问过我,为什么不直接用装饰器包一层工具函数,或者用中间件统一拦截?我的回答是:第三方工具你改不了。项目里经常要用现成的搜索工具、数据库查询工具,直接在Agent里注册。你不可能去改第三方库源码加日志。但回调机制是全局统一挂在执行链路外层的,无论来自哪里的工具,只要被Agent调用,回调就能收到事件。
回调另一个不可替代的点是嵌套关系。Agent调用工具,工具内部可能又会触发大模型调用,这些调用是嵌套的。回调事件天然携带parent_run_id,可以在日志里还原完整的嵌套调用树。装饰器只能管到你主动包的那一层,往下再深一层就无能为力了。
3. 手写Agent日志工具:一份可以直接抄的完整实现
3.1 先设计日志记录的数据模型
写日志工具的第一步不是敲代码,而是想清楚每条日志要记什么。我最终定下的核心字段如下:
| 字段 | 类型 | 含义 |
|---|---|---|
| event | string | 事件类型,如llm_start、tool_end |
| run_id | string | 当前事件所属的运行ID |
| parent_run_id | string | 父运行ID,用于串起调用层级 |
| session_id | string | 会话ID,区分不同任务 |
| ts | float | 事件触发的时间戳 |
| tool | string | 工具名称 |
| tool_input | string | 传给工具的原始参数字符串 |
| tool_output | string | 工具返回结果 |
| prompt | string | 发给大模型的提示词文本 |
| output | string | 大模型生成结果 |
| latency_ms | float | 事件耗时,毫秒 |
| token_usage | object | 模型token消耗 |
这个设计尽量贴近实际需求。tool_input必须单独成字段,不能混合在output里,否则后期查询困难。run_id和parent_run_id是LangChain回调自带的信息,千万不要丢,它是你把一条调用链串起来的线索。
3.2 基于BaseCallbackHandler的最小实现
下面这段代码是一个可运行的最小实现。我刻意控制长度,把核心部分展示出来,你可以直接复制到项目里当底座。
python复制import json
import time
import re
from typing import Any, Dict, List, Optional
from uuid import UUID
from langchain_core.callbacks.base import BaseCallbackHandler
from langchain_core.outputs import LLMResult
def clip_and_mask(text: str, limit: int = 1000) -> str:
"""截断超长文本,并给常见敏感信息打码。"""
if not text:
return ""
text = str(text).strip().replace("\n", " ")
text = re.sub(r"(sk-[A-Za-z0-9]{4})[A-Za-z0-9]+", r"\1****", text)
text = re.sub(
r"(api[_-]?key[\"'\s:=]+)[^\"'\s,]+",
r"\1****",
text,
flags=re.IGNORECASE,
)
if len(text) > limit:
return text[:limit] + f"...[truncated {len(text) - limit} chars]"
return text
class AgentLogHandler(BaseCallbackHandler):
"""Agent日志处理器:把关键事件写入JSON Lines文件。"""
def __init__(self, log_path: str = "agent_run.jsonl"):
self.log_path = log_path
self._start_time: Dict[str, float] = {}
def _write(self, record: dict):
with open(self.log_path, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
def _base(self, event: str, **kwargs) -> dict:
run_id = kwargs.get("run_id")
parent_run_id = kwargs.get("parent_run_id")
record = {
"event": event,
"run_id": str(run_id) if run_id else None,
"parent_run_id": str(parent_run_id) if parent_run_id else None,
"ts": time.time(),
}
if run_id and run_id in self._start_time:
record["latency_ms"] = int((time.time() - self._start_time[run_id]) * 1000)
return record
def on_llm_start(self, serialized: Dict[str, Any], prompts: List[str], **kwargs):
self._start_time[kwargs.get("run_id")] = time.time()
record = self._base("llm_start", **kwargs)
record["prompt"] = clip_and_mask(prompts[0]) if prompts else ""
self._write(record)
def on_llm_end(self, response: LLMResult, **kwargs):
record = self._base("llm_end", **kwargs)
if response.generations:
text = response.generations[0][0].text
record["output"] = clip_and_mask(text, limit=2000)
try:
if hasattr(response, "llm_output") and response.llm_output:
record["token_usage"] = response.llm_output
except Exception:
pass
self._write(record)
def on_tool_start(self, serialized: Dict[str, Any], input_str: str, **kwargs):
record = self._base("tool_start", **kwargs)
record["tool"] = serialized.get("name")
record["tool_input"] = clip_and_mask(input_str)
self._write(record)
def on_tool_end(self, output: str, **kwargs):
record = self._base("tool_end", **kwargs)
record["tool_output"] = clip_and_mask(output, limit=2000)
self._write(record)
def on_agent_action(self, action, **kwargs):
record = self._base("agent_action", **kwargs)
record["tool"] = getattr(action, "tool", None)
record["tool_input"] = clip_and_mask(str(getattr(action, "tool_input", "")))
record["thought"] = clip_and_mask(getattr(action, "log", ""), limit=1500)
self._write(record)
def on_agent_finish(self, finish, **kwargs):
record = self._base("agent_finish", **kwargs)
record["final_output"] = clip_and_mask(str(getattr(finish, "return_values", "")), limit=2000)
self._write(record)
这段代码有几点值得说明。第一,所有文本统一走clip_and_mask,防止把几十页的文档内容原样写进日志。第二,latency_ms通过记录每个run_id的开始时间来计算,能直观看出哪个环节耗时最长。第三,on_agent_action里我额外存了thought字段,这就是Agent在思考阶段的推理文本,排查决策问题时最有用。
3.3 JSON Lines落地、截断与脱敏
这个Handler直接以追加模式写JSON Lines文件,也就是每行一个JSON对象。选JSON Lines而不是普通文本,是因为它可以被标准工具直接分析。命令行的jq能快速过滤出某个事件类型:
bash复制jq -r 'select(.event == "tool_end") | [.tool, .tool_output] | @tsv' agent_run.jsonl
截断和脱敏这两件事我建议永远别省。Agent一旦操作真实文档,返回内容可能几十KB。你要是全量写入日志,磁盘会先报警,而且真正想查的内容会被淹没在垃圾信息里。我的经验是:LLM的prompt截断到1000字符,工具输出截断到2000字符,最终答案截断到2000字符。这几个阈值够复盘用,又不会撑爆文件。
脱敏主要是两处:OpenAI风格的sk-密钥、api_key这类字段。代码里的正则能把密钥中间部分替换成星号,保留前四位方便人眼快速确认是哪个key。注意原生日志文本里如果包含用户隐私,你还需要按自己的合规要求补充其他脱敏规则。
3.4 长时间运行Agent的日志轮转策略
上面最小实现是同步写文件,单机调试完全够用。但如果你要把Agent跑成服务,就必须考虑两件事:文件无限增长和写文件阻塞主线程。
我的方案是让日志写入走独立线程,用queue把记录丢进去,消费者线程负责落盘。这样Agent主流程不会被磁盘变慢拖累。日志文件按时间轮转,我习惯每小时或每天切一个新的,保留最近N份,避免磁盘被撑爆。骨架代码如下:
python复制import queue
import threading
class AsyncLogWriter:
def __init__(self, log_path: str):
self.queue: queue.Queue = queue.Queue()
thread = threading.Thread(target=self._loop, daemon=True)
thread.start()
def _loop(self):
while True:
record = self.queue.get()
if record is None:
break
with open(self.log_path, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
实际使用时,把AgentLogHandler里的_write方法改成一个put方法即可。这里不贴完整改造代码,因为核心思路就是“同步收集、异步落盘”,不同团队的项目结构差异很大。
4. 路径工具:把Agent的行动范围圈在可控边界内
4.1 Agent里的“路径”到底指什么
标题里的“路径工具”容易被误解成只是处理文件路径的工具。我在项目里总结了至少三层含义:
第一层是文件系统路径,就是Agent在调用文件工具时访问的目录。这一层最直接,也最容易出安全事故。你不约束它,它可能把/etc目录下的敏感配置读一遍。
第二层是工具注册路径,也就是Agent能访问哪些工具的名字空间。工具多了以后,要给Agent一个清晰的“工具清单”和“工具名称映射”,防止Agent在工具名上产生幻觉。第三层是知识库索引路径,在Agent + RAG场景里,retriever索引的位置、chunk的命名规则,都属于路径管理范畴。
这一节重点处理第一层和第二层。第三层在第6章提一下思路。
4.2 基于WorkspaceGuard的工作区约束实现
文件路径约束最朴素也最安全的做法是“白名单 + 解析后校验”。直接写字符串比较是不行的,因为路径有太多花样:相对路径、软链接、环境变量、波浪号。你拦了“../etc/passwd”,Agent还可以绕道“./workspace/../../etc/passwd”。
我写了一个WorkspaceGuard类,核心逻辑只有两个步骤:先做路径解析,再判断是否位于根目录之下。
python复制import os
from pathlib import Path
from functools import wraps
class WorkspaceGuard:
"""把文件访问限制在指定工作区内。"""
def __init__(self, root: str):
self.root = Path(root).resolve()
def check(self, target: str) -> str:
p = Path(target).expanduser().resolve()
if p == self.root or self.root in p.parents:
return str(p)
raise PermissionError(f"Path {target} is outside workspace {self.root}")
def __call__(self, func):
@wraps(func)
def wrapper(path: str, *args, **kwargs):
safe_path = self.check(path)
return func(safe_path, *args, **kwargs)
return wrapper
注意解析顺序:先expanduser把“~”展开成真实用户目录,再resolve解析所有软链接和相对路径。resolve之后得到的绝对路径,如果不在工作区根目录之下,直接抛出PermissionError。我特意允许路径等于工作区根目录本身,因为有些工具要读取目录列表。
4.3 把路径校验织进Tool层
有了Guard类,下一步是把它接进工具。最简单的就是把装饰器打在工具函数上,前提是工具函数第一个参数必须是路径参数。
python复制guard = WorkspaceGuard("./workspace")
@guard
def read_file(path: str) -> str:
with open(path, "r", encoding="utf-8") as f:
return f.read()[:3000]
但有些工具的函数签名不是前缀路径,而是把文件名放在其他位置,或者工具本身就是第三方对象。这时候用工厂函数包一层更灵活:
python复制from functools import wraps
def safe_tool(tool_fn, guard):
@wraps(tool_fn)
def wrapped(path: str, *args, **kwargs):
safe_path = guard.check(path)
return tool_fn(safe_path, *args, **kwargs)
return wrapped
设计上有个小细节:校验一定放在工具函数执行之前。这看起来是废话,但我在线上见过有人把校验写在函数尾部,等于脱了裤子放屁。路径校验必须位于任何文件系统操作之前,而且是同一进程内、同一调用栈内,不能用异步延迟校验。
4.4 在提示词里给模型标注“能去哪”
光靠代码拦截还不够,Agent在推理阶段就该知道自己的行动边界。我的习惯是在System Prompt里明确写出工作区路径,并给出当前目录下的文件列表。这能大幅减少Agent瞎猜路径、反复构造错误路径的次数。
一个简单但有效的做法是:在搭建工具清单时,额外向Agent注入“当前工作区允许访问,且只能访问以下目录”的说明。如果你用LangChain的PromptTemplate,可以这样设计:
plaintext复制你是一个只能操作本地工作区的Agent。
工作区根目录为 {workspace}。
所有文件操作必须使用绝对路径,且路径必须位于工作区内。
如果需要访问工作区之外的内容,直接拒绝并说明原因,不要尝试猜测路径。
配上代码层的WorkspaceGuard,双保险。提示词负责让Agent“不想去”,代码负责让Agent“去不了”。
5. 联调实战:一个带日志和路径管控的最小Agent
5.1 组装完整的Agent执行环境
理论讲完,直接上一个可运行的最小组合。我用create_react_agent搭配AgentExecutor,模型选用本地ChatOllama。你如果用的是OpenAI接口或国内模型,把model那一行替换掉就行。
python复制from langchain.agents import create_react_agent, AgentExecutor
from langchain_core.prompts import PromptTemplate
from langchain_ollama import ChatOllama
model = ChatOllama(model="qwen2.5:7b", temperature=0)
guard = WorkspaceGuard("./workspace")
@guard
def read_file(path: str) -> str:
with open(path, "r", encoding="utf-8") as f:
return f.read()[:3000]
@guard
def list_files(path: str = "./") -> str:
entries = os.listdir(path)
return "\n".join(entries)
tools = [read_file, list_files]
prompt = PromptTemplate.from_template(
"""You are an agent with restricted access to a local workspace.
Workspace root: {workspace}
Tools:
{tools}
Use the following format:
Thought: your reasoning
Action: tool name
Action Input: the input value
Observation: tool result
... (repeat thought/action/observation as needed)
Final Answer: your response
User input: {input}
{agent_scratchpad}"""
)
log_handler = AgentLogHandler("agent_run.jsonl")
agent = create_react_agent(model, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools, verbose=False)
result = executor.invoke(
{"input": "看一下工作区里有哪些文件,然后告诉我 report.md 的前两行内容。", "workspace": "./workspace"},
config={"callbacks": [log_handler]},
)
print(result["output"])
如果你是LangGraph路线,handler同样通过config传入,接口完全一致。这套代码我本地跑过,关键在于:create_react_agent的prompt必须包含input、tools、agent_scratchpad变量,额外变量workspace通过invoke时传进去,这没问题。
5.2 一次完整调用背后的日志轨迹
跑完上面的代码,打开agent_run.jsonl,你会看到类似下面的一组事件。这是经过删减的真实片段:
json复制{"event": "llm_start", "run_id": "a1b2...", "prompt": "You are an agent with restricted..."}
{"event": "agent_action", "run_id": "a1b2...", "tool": "list_files", "tool_input": "{\"path\": \"./workspace\"}"}
{"event": "tool_start", "run_id": "a1b2...", "tool": "list_files", "tool_input": "./workspace"}
{"event": "tool_end", "run_id": "a1b2...", "tool": "list_files", "tool_output": "report.md\nnotes.txt"}
{"event": "llm_start", "run_id": "c3d4...", "prompt": "...report.md\nnotes.txt..."}
{"event": "agent_action", "run_id": "c3d4...", "tool": "read_file", "tool_input": "{\"path\": \"./workspace/report.md\"}"}
{"event": "tool_start", "run_id": "c3d4...", "tool": "read_file", "tool_input": "./workspace/report.md"}
{"event": "tool_end", "run_id": "c3d4...", "tool": "read_file", "tool_output": "# 项目周报..."}
{"event": "llm_start", "run_id": "e5f6...", "prompt": "...前两行内容..."}
{"event": "agent_finish", "run_id": "e5f6...", "final_output": "report.md 的前两行是:..."}
把事件按时间排列,就能清晰看到Agent的完整决策链:先列目录,确认文件存在,再读取目标文件,最后生成总结。哪一步慢、哪一步返回了异常内容,日志里都摆得明明白白。
5.3 用日志反推Agent决策缺陷
日志工具最大的价值不是事后追责,而是帮你发现Agent的“坏习惯”。我举两个真实遇到的例子。
第一个是重复调用。日志里连续出现三条agent_action,tool都是read_file,tool_input是完全一样的路径。这说明大模型没有记住工具结果,或者上下文里的观测信息被遗漏了。看到这种模式,我会去检查提示词里的agent_scratchpad格式,或者调大上下文长度。
第二个是路径拼接错误。有一次tool_input是“workspace/report.md”,但WorkspaceGuard解析后拦截了。原因是Agent用了相对路径,而工作区根目录是绝对路径。最终我只在提示词里加了一句“必须使用绝对路径”,问题就消失了。没有日志工具,你只会看到一个空的报错,不会知道Agent到底在想什么。
6. 我踩过的坑:日志丢失、Token爆炸与路径绕过
6.1 别把日志直接塞回上下文,Token成本直接翻倍
踩过最大的坑是:为了让Agent“记住”自己刚才干了什么,有人会把日志文本直接拼进下一轮prompt。这是我见过最贵的做法。日志是给人看的,不是给模型看的。模型判断下一步行动需要的是工具返回的observation和中间推理结果,而不是那一大坨JSON。你把日志塞回上下文,一来Token消耗直接翻倍,二来日志里的截断省略号还会干扰模型判断。
正确做法是:日志文件只给开发者在排障时看。如果需要让Agent具备更强的记忆能力,应该用专门的消息记录机制或者向量记忆,而不是用调试日志替代。
6.2 异步环境下回调不触发,文件写入会堵死Agent
LangChain在高并发场景下会走异步路径。如果你的Handler是同步类,某些异步调用链上可能收不到回调。解决办法是继承AsyncCallbackHandler,把on_llm_start等钩子改成async def,或者确保回调配置传到异步链路上。
另一个坑是日志写入本身变成性能瓶颈。Agent放在线上服务里,刚开始很正常,跑几天后用户抱怨变慢,查了半天发现是日志文件越写越大、每次open都变慢。后来我改成队列异步落盘,问题立刻消失。任何日志方案都必须考虑写阻塞。
6.3 路径校验被软链接和相对路径绕过
路径这块我一开始也犯过低级错误。第一版只判断字符串里有没有“..”,结果Agent用“./workspace/../../etc/passwd”轻松绕过。后来用Path.resolve(),把软链接和相对路径都解析成真实绝对路径再比较,才算堵住。还有一次在Windows上踩了盘符大小写的坑,D盘路径和d盘路径比较结果不相等。跨平台项目要记得在比较前调用os.path.normcase统一大小写。
还有一个边界案例:工作区内如果存在指向工作区外的软链接,resolve之后会发现真实目录在外面。这必须拦截。我见过有人只在打开文件后检查一次路径,但文件是软链接,目标根本不在工作区。所以Path.resolve()这一步绝不可以省略。
6.4 RAG检索过程的日志追踪值得额外留意
最后说一下RAG项目怎么复用这套日志工具。Agent + RAG的常见做法是把retriever封装成一个检索工具,Agent决定什么时候去查知识库。这个场景里,除了工具日志,还要关注on_retriever_start和on_retriever_end事件,它们能记录检索query命中了哪些chunk。我在知识库项目里给每个chunk加了统一的文档ID,日志里就能追踪到Agent用的到底是哪一段原文。
RAG和Agent结合时的排查难点往往是“答案错了,不知道是检索错了还是生成错了”。有了retriever回调日志,这个问题变得一目了然:日志显示检索结果里没有正确答案,那就是知识库切片或embedding的问题;检索结果是对的但答案错了,那就是大模型理解和组织的问题。这一刀切下去,排查效率能提升一个量级。
最后再分享一个小习惯。我现在无论写什么工具,都会先确认日志能回答三个问题:它做了什么、为什么这么做、结果是什么。再确认路径边界是否明确。这两件事做好了,Agent项目才算真正达到可以上交的水平。后续如果想把日志升级成指标监控,或者把路径工具改成更细粒度的权限策略引擎,思路也是从这一套地基上长出来的。
