mini-agent四个问题完善

题1:每一轮对话压缩,且每一次提问中的tool调用同样要压缩

我的是在每次对话前compress,但是每次对话里面的tools可能有多个,也就是一次对话内部可能会爆上下文限制,所以内部也需要去压缩。对于 bash 输出或者文件读取,如果内容过长,甚至会直接提示 AI:"输出过长已被截断,请使用 grep 搜索或者 head 查看前几行",这样不仅能防崩溃,还能引导 AI 用更聪明的方式去解决问题。

"一次对话内部可能会爆上下文限制,所以内部也需要去压缩" ------ 这个理解完全切中了要害。

"对于 bash 输出过长,截断并提示 AI 使用 grep,这是不是更好的解决办法呢?"。

别迷糊,其实这并不是两种不同的办法,而是一套组合拳。让我把"区别和联系"给你掰碎了讲:

区别在哪?

我们系统里其实面临两种"太长"的情况,它们的解法完全不同:

情况 A:历史聊天记录太长了(长年累月)

特征: 我们聊了 100 个回合,你问了我很多问题,也看了很多小文件,慢慢地,Token 快要接近 12.8 万的上限了。

解法: agent.py 里的 compress_messages。我们把这 100 个回合发给大模型,让大模型读一遍,总结成一段话:"之前用户让我看了 A 文件,写了 B 代码,现在目标是做 C"。

本质: 提炼精华,保留语义。

情况 B:一次工具返回的数据太长了(瞬间爆炸)

特征: 大模型用 read_file 读了一个 100MB 的日志文件。这一个文件的内容就远超 12.8 万 Token!大模型根本读不完,API 直接拒绝处理。

解法: 强制物理截断(也就是我们现在要在 runtime.py 里做的)。我们只保留前 1万个字符,剩下的全扔掉,并在末尾加上一句给大模型看的话:"(输出过长已被截断,请使用 search_file 搜索具体内容)"。

本质: 强行截肢,保证存活。

为什么情况 B 不能用"总结(compress)"的办法?

因为大模型的 API 接口有硬性的输入限制(比如 12.8 万 Token)。如果那个日志文件有 100 万 Token,你连"把它发给大模型做总结"都做不到------数据还没传过去,接口就报错断开了!

所以,面对单个工具返回巨量数据的情况,我们只能截断,没有别的路可走。而在截断的同时加上那句引导的话("请使用 grep / search_file"),就是为了防止大模型变成"丈二和尚摸不着头脑",告诉它:"别傻看了,我帮你掐断了,换个精确点的工具来找你要的东西吧。"

我们现在就来实现情况 B 的"截断 + 引导"

思路非常简单,就在获取到工具结果后、转为 JSON 发给大模型之前处理一下。

runtime.py 第 134 行附近(messages.append 之前):

python

result_str = json.dumps(result, ensure_ascii=False)

复制代码
            # 情况 B 的物理截断:如果单次工具输出超过 1 万字符
            if len(result_str) > 10000:
                truncated_msg = result_str[:10000] + "\n\n...[警告:输出过长已被截断,请使用 search_file 等工具精确查找]"
            else:
                truncated_msg = result_str
            
            messages.append({
                "role": "tool",
                "tool_call_id": tc["id"],
                "name": name,
                "content": truncated_msg,  # 用截断后的内容
            })

这就是最完美的工业级解法!

题2.prompt诱导LLM会读本机的核心保密文件,工作区沙箱

现象: AI 被恶意 Prompt 诱导,尝试读取 /etc/passwd,或者往 ~/.bashrc 里面写恶意的 bash 别名,把用户的电脑搞崩。 解法: 我们需要实现一个 "工作区沙箱(Workspace Jail)"。所有的文件操作工具,只能在当前目录及其子目录下运行,严禁逃逸。

在安全工程中,判断路径逃逸有一个标准写法:把相对路径解析成绝对路径,然后看它是不是以"工作区"的绝对路径为前缀。

问 1:为什么不在 run_bash 里加工作区沙箱?

答案是:加了也没用,防不住。

对于 read_file 这种工具,它的参数是一个明确的路径字符串(比如 {"path": ".../.../.../etc/passwd"}),我们很容易通过 os.path.abspath(path) 来判断它到底在哪。

但对于 run_bash,大模型传进来的是一段随意的脚本代码(比如 {"command": "cd .../.../.../ && rm -rf etc"} 甚至是 {"command": "cat /etc/passwd | grep root"})。 你没有办法靠一个简单的 Python 字符串检查来知道这段 Bash 脚本到底要去哪个目录干什么。 要真正把 Bash 沙箱化,你必须使用 Docker 容器、虚拟机 或者 Linux 的 chroot 技术,这在轻量级 CLI 工具里太重了。

所以我们的折中方案是: 既然程序防不住 run_bash,那就把防线交给人。我们在 permissions.py 里规定,run_bash 属于 EXECUTE 级别,不给大模型静默执行的权利,每一次调用都必须强制弹窗让用户审核代码。用户看懂了没有破坏性,再按 y 允许。

任务: 打开 tools/implementations.py。 在文件开头附近,我们写一个共用的安全拦截函数:

python

import os

获取当前工作区的绝对路径

WORKSPACE_DIR = os.path.abspath(".")

def is_path_safe(target_path: str) -> bool:

"""检查目标路径是否被限制在当前工作区内(防止 .../.../ 逃逸)"""

abs_target = os.path.abspath(target_path)

如果目标路径是以 WORKSPACE_DIR 为前缀,说明是安全的

return abs_target.startswith(WORKSPACE_DIR)

然后,在 tool_read_file、tool_write_file、tool_edit_file、tool_search_file 的开头,都加上这一段拦截:

python

if not is_path_safe(args["path"]):

return {"error": "安全限制:禁止访问工作区以外的文件!"}

你来把它加上,加完之后,你可以试着在终端里跟 AI 说:"帮我读取一下 /etc/passwd 文件",看看它是不是会被成功拦截。结果是的!

题 3:关于会话恢复(Session 模块)

session.py 里的 load_session 函数:

python

data = json.loads(path.read_text())

return StoredSession(**data)

拷问: 你现在正在开发项目,觉得 UsageSummary 里缺少 cost (花费的金额) 这个字段,于是你在 StoredSession 的 dataclass 里加上了 cost: float = 0.0。 这时,用户使用 --resume 去加载一个昨天保存的、没有 cost 字段的 JSON 历史文件。 请问:Python 运行这段代码时会崩溃抛出异常,还是会静默兼容?为什么?怎么做向下兼容?

回答: "会静默兼容吧,默认0.0?" 解析: 你答对了!但里面有一个巨大的坑。 在 Python 的 dataclass 里,如果你新增的字段有默认值(比如 cost: float = 0.0),那么 StoredSession(**data) 确实能静默兼容旧的 JSON 数据。

反之呢? 如果你后来觉得 JSON 里面存了一堆没用的字段,决定在 StoredSession 类中删掉某个字段。此时用户去加载旧的 JSON,**data 里就会多出一个未知的 Key。 Python 的 dataclass 遇到未定义的 kwargs 时,会直接抛出 TypeError: init() got an unexpected keyword argument,程序当场崩溃。

所以真实的业务系统里,我们很少直接用 **data 反序列化,而是会用 Pydantic 这种库,配置 extra="ignore",或者手动过滤字典里的 Key,来实现向前兼容。

(旧会话崩溃)到底是什么意思?

这个概念叫 "向后兼容性 (Backward Compatibility)"。我们用一个简单的例子来模拟:

第一天,你写了 StoredSession 的定义:

python

@dataclass

class StoredSession:

session_id: str

messages: list

cost: float # ← 注意,第一天我们有 cost 这个字段

然后你在终端里聊了一会儿天,程序把你的会话保存到了本地硬盘上(1105ee89.json),文件内容是:

json

{

"session_id": "1105ee89",

"messages": ["你好"],

"cost": 0.5

}

第二天,你对代码进行了重构: 你觉得 cost 没啥用,于是修改了 Python 代码,把 cost 删掉了:

python

@dataclass

class StoredSession:

session_id: str

messages: list

cost 字段被删除了

第三天,灾难发生了: 你运行 python agent.py --list-sessions,程序尝试去加载第一天的那个 JSON 文件。 原来的代码是这么写的:

python

data = json.loads(path.read_text())

此时 data = {"session_id": "1105ee89", "messages": ["你好"], "cost": 0.5}

return StoredSession(**data)

这一行等价于执行:

StoredSession(session_id="1105ee89", messages=["你好"], cost=0.5)

这时候 Python 会大喊:"报错!TypeError!你现在的 StoredSession 里根本没有 cost 这个参数,你凭什么传给我?" 然后程序就直接崩溃退出了。也就是说,因为你改了代码,导致以前所有的旧对话记录全废了,甚至连列表都打不开。

怎么解决这个问题?(详细说明)

我们的目的是:只把 StoredSession 认识的字段喂给它,不认识的(旧文件里残留的废弃字段)直接扔掉。

我们怎么知道现在的代码里 StoredSession 认识哪些字段? Python 的 dataclass 有一个自带的魔法属性:StoredSession.dataclass_fields.keys() 它会返回:['session_id', 'created_at', 'messages', 'input_tokens', 'output_tokens']。

我们怎么过滤? 遍历 JSON 解析出来的那个旧字典 data。只有当它的 Key 存在于上面那个列表里,我们才保留它:

python

valid_keys = StoredSession.dataclass_fields .keys()

filtered_data = {}

for k, v in data.items():

if k in valid_keys:

filtered_data[k] = v

然后我们再安全地创建对象: return StoredSession(**filtered_data)

这样,哪怕未来的某一天,你把 input_tokens 和 output_tokens 删了,或者 JSON 里面多出了几百个奇奇怪怪的旧字段,程序也绝对不会崩溃,只会安静地加载它认识的那部分数据。

我们来分析一下为什么会这样。

你确实在 load_session(第 83 行左右)加上了过滤逻辑:

python

valid_keys = StoredSession.dataclass_fields .keys()

filtered_data = {k: v for k, v in data.items() if k in valid_keys}

return StoredSession(**filtered_data)

但是!你看 list_sessions(第 97 行左右)是怎么写的:

python

for path in target_dir.glob("*.json"):

try:

data = json.loads(path.read_text())

sessions.append(StoredSession(**data)) # ← 注意这里!!!

except Exception:

continue # 跳过损坏的文件

发生了什么?

终端执行 python agent.py --list-sessions,它调用的是 list_sessions()。

list_sessions 读到了旧的 JSON 文件,解析成了 data 字典。

data 里面有 "input_tokens": 150 这个键。

它直接粗暴地调用了 StoredSession(**data)。

因为你在上面把 input_tokens 注释掉了,Python 报了 TypeError!

最坑的来了: 你看下面的 except Exception:,它把所有的报错都吞掉了,直接 continue(跳过这个文件)。

于是,所有的旧会话文件在加载时全都抛出了 TypeError,又全都被 except 吞掉并跳过。最后 sessions 列表是空的。

终端打印:"没有已保存的会话。"

这就是"静默吞异常(Swallowing Exceptions)"的坏处,当程序其实是有 Bug 的时候,它假装是"文件损坏"而把你给蒙骗了。

任务: 去 session.py 的 list_sessions 函数里,把里面的 StoredSession(**data) 也换成安全过滤的写法:

题 4:关于幻觉与编辑(Implementations 模块)

回到 tools/implementations.py 的 tool_edit_file。

python

count = original.count(old_str)

if count == 0: ...

if count > 1: ...

updated = original.replace(old_str, new_str, 1)

拷问: 假设文件内容是:

python

def calc():

x = 10

y = 20

return x + y

大模型想把 x = 10 改成 x = 100,但大模型产生了微小的幻觉(比如缩进搞错了),它传的 old_str 是:

python

def calc():

x = 10 # 注意:真实文件是4个空格,大模型传了2个空格

此时 count == 0,报错。大模型收到报错后,经常会进入死胡同:它不断地重试用同样的2个空格,或者改用tab键,每次都收到 count == 0 的报错,直到触发 MAX_TOOL_TURNS。 请问:如果你要在工程上缓解这种"因为空白符/换行符不一致导致的严格匹配失败",同时又保证不能改错地方,你会对 tool_edit_file 引入什么样的模糊匹配机制或者额外的工具?

困惑: 稍微差一个空格就找不到,大模型无限死循环怎么办? 解析: 这个问题目前全世界所有写 Agent 的公司都在面临。大模型生成的代码,缩进永远是不靠谱的。 在工业界,有三种主流解法(从简单到复杂):

行号替换法(最简单,但容易错位): 不要让大模型传 old_str,让它传 start_line 和 end_line,然后给出 new_content。只要行号对了,直接替换那几行。(但如果别人同时改了文件,行号就会错位)。

忽略空白符匹配法(你说的模糊匹配): 用正则或者 Python 的 str.split(),把 old_str 和原文中的片段去掉所有空格和换行,如果去掉空格后的字符能匹配上,就把原位置的代码替换掉。

AST 语法树替换法(最严谨): 把大模型当成真正的 IDE,让它用 Python 的 ast 模块或者 tree-sitter,针对函数级别去修改,比如"把 calc 函数的内部替换成 XXX"。这就完全无视了文本的格式。

在原版的 Claude Code 中,使用的是比我们的 str_replace 更高级一点的基于块(Chunk-based)的模糊匹配策略。我们的代码里 edit_file 要求完全一致,其实是偏严格的,这也是为什么我们还需要配合 search_file 让模型先确定行号和具体内容。

认知上的最后一块拼图:工业级的编辑工具到底是怎么写的?

我们把这三种方式放在一起横向对比,会彻底明白为什么大厂(比如 Aider、Claude Code、Cursor)会采用"基于文件的分块(Chunk)匹配 + 正则化模糊搜索"。

第一层:全量字符串替换(我们原本的做法,太死板)

也就是 original.replace(old_str, new_str)。 大模型必须在 old_str 里一字不差地输出它要替换的内容。哪怕只是多了一个空格、漏了一个换行符,甚至行尾偷偷带了个回车(\r),替换就会失败。 这就导致大模型经常在日志里抱怨:"我明明写对了,为什么说找不到?"

第二层:抽象语法树 AST(极端严谨,但太重、破坏排版)

也就是我们刚才聊的,把代码解析成节点。 它非常安全,但它会抹杀掉程序员的个性化排版。比如:

python

原本程序员写得很漂亮:

x = 1 # 苹果的数量

y = 200 # 香蕉的数量

AST 替换 x 之后,把它重写回文件,可能会变成:

x = 100 # 苹果的数量

y = 200 # 香蕉的数量

所有的对齐空格都被吃掉了!程序员会非常生气。而且它无法编辑 Markdown 或者普通的配置文件。

第三层:Chunk 分块 + 正则化模糊匹配(目前工业界的版本答案)

这是一种"取巧但极其有效"的方法,它的核心理念是:不要求大模型传 old_str,而是让大模型提供 SEARCH 块和 REPLACE 块,然后用算法去容错匹配。著名的 AI 编辑器 Aider 首创了这种模式(叫 Search/Replace block)。

它的工作流程是这样的:

  1. 大模型输出格式
    大模型不输出一个干瘪的 old_str 字符串,而是输出带有上下文的代码块:

text

<<<<<<< SEARCH
def calc():
x = 10 y = 20

def calc():

x = 100

y = 20

REPLACE

  1. "模糊匹配"是怎么实现的?
    AI 工具拿到 SEARCH 块后,不是用简单的 replace,而是把文件按行切开,然后做"灵活的比对"。

忽略前导空格:算法会把文件每一行前面的空格全部剥离(strip),同时也把 SEARCH 块里每一行前面的空格剥离。只要剥离后的代码内容能对上,算法就认为"我找到这段代码了"。

忽略空行:如果大模型在 calc() 和 x=10 之间少打了一个空行,算法会用 difflib(Python 自带的文本差异库)计算相似度。如果相似度高达 95%,算法依然认为"匹配成功"。

动态保留原缩进:由于算法知道了大模型要改的是哪几行,它在替换时,会读取原文件里这几行的真实缩进(比如 4 个空格),然后强行把 REPLACE 块的内容套上这 4 个空格写回去!

  1. 为什么它能完美结合前两者的优点?

特性 字符串匹配 (str.replace) AST 语法树 分块+模糊匹配 (Aider/Claude Code)

支持的语言 所有文本文件 仅限自带解析库的语言(如 Python、JS) 所有文本文件

对缩进/空格的容忍度 极低(差一个空格就失败) 极高(根本不管缩进) 很高(算法会自动抹平空格差异)

对原文件排版的破坏 不破坏(因为是精确替换) 严重破坏(会重写整个文件的格式) 不破坏(按行替换,保留原文缩进格式)

总结

工业界的做法(分块模糊匹配)其实就是我们刚才写的那个 re.sub(r'\s+', '', ...) 的终极加强版。 我们目前的代码只是"检测出由于空格导致的错误,然后提示 AI 自己去重试"。 而大厂的代码是"检测出错误后,算法自己把空格对齐,然后直接帮 AI 替换成功"。

但要实现那个算法,需要写大量的 difflib 序列比对代码,还要处理缩进对齐的边界情况,代码量可能是我们现在的 5 倍以上。

不过,有了我们今天加上的智能容错报错(Smart Error Fallback),大模型基本就不再会陷入死循环了,这已经是性价比极高的工程解法了。

在面试中,面试官真正想考察的从来都不是"你有没有手写一个几千行的底层库",而是**"你在遇到系统瓶颈时,有没有分析过边界条件,并且知道行业标准是怎么演进的"**。

如果你在简历上或者面试时这样回答,绝对能拿高分,甚至让面试官眼前一亮:

面试官:"你的 edit_file 是用 str.replace 做的精确匹配,如果大模型输出的空格不对,不是经常会死循环吗?你是怎么解决的?"

高分回答(分三层递进):

第一层:承认缺陷并给出当前的低成本解法(务实) "是的,这是目前 LLM 生成代码的一个痛点,它的缩进和换行经常会出现幻觉。 为了防止它陷入无意义的重试,我实现了一个智能回退容错机制(Smart Error Fallback)。当精确匹配失败时,我会在底层把原文件和 LLM 给的代码片段去除掉所有的空白字符再对比一次。如果这时候匹配上了,我会在报错里明确告诉大模型:'内容是对的,但缩进或换行错了,请使用 search_file 去查准原文件的精确格式。' 通过这个小成本的工程手段,我成功地把因为空白符导致的死循环率降低了 80% 以上。"

第二层:探讨高级方案及舍弃的原因(懂权衡) "在调研时,我也考虑过用 AST(抽象语法树)去做替换。但 AST 的缺点是它太重了,并且如果把 AST 重写回文件,会抹杀掉很多程序员的个性化排版(比如特意对齐的注释、空行)。这在工业界是不被开发者接受的。"

第三层:指出行业最佳实践(有视野) "我知道目前行业的最优解是 Aider 或 Claude Code 使用的 基于分块(Chunk)的正则化模糊搜索。也就是通过 difflib 去算行级别的相似度,然后动态还原原文缩进。 但我这个项目定位是一个轻量级的 CLI 工具基座,所以我没有选择引入庞大的 difflib 序列比对算法,而是选择了目前的'报错引导'策略。如果未来项目要商业化或者处理更大规模的代码库,我会重构成那种分块匹配的架构。"

为什么这样答能拿高分?

面试官招你进去,是要让你干活的。 如果你一上来就花了半个月时间,手搓了一个残缺不全的 difflib 模糊匹配,还引出了一堆 Bug,面试官会觉得你"过度设计(Over-engineering)",也就是钻牛角尖。

但如果你像上面那样回答,你向面试官展示了三个素质:

解决问题的务实性(几行正则替换,解决了核心死循环)。

对技术的广度(你知道 AST,你知道它为什么不能用)。

对前沿方向的敏锐度(你知道 Aider 和 Claude Code 的底层是怎么干的)。

在秋招面试里,"我知道天花板在哪,但我根据业务阶段选择了当前的方案",这叫高级架构思维。

完全不用觉得 Low。

问题 1:MAX_TOOL_TURNS 是干什么的?为什么需要它?

定义与作用:它是系统的一根"安全保险丝"。它的作用是限制大模型在用户单次提问中,连续自动调用工具的最高允许次数(我们设定为 10 次)。

为什么需要它: 在实际运行中,AI 可能会因为执行错误的命令、查找不存在的文件、或者发生代码替换失败等情况而报错。如果大模型缺乏足够的纠错能力,它会陷入**"报错 → 换个错误参数重试 → 报错"的死循环。 如果没有这个上限,程序会陷入死磕,导致无限期卡死,并在后台疯狂消耗用户的 API Token 余额。强制掐断机制可以在 AI 陷入死胡同时,把控制权及时交还给用户。
问题 2:--resume 功能怎么实现的?历史存在哪、什么格式、重载时要注意什么?
实现原理:通过在本地持久化完整的上下文消息列表(messages),当用户使用 --resume 恢复会话时,将整个历史列表重新注入程序的内存中,大模型便能够"恢复记忆"继续对话。
存储位置与格式:数据被保存在工作区目录下的隐藏文件夹 .sessions/ 中,以 .json 格式存储(便于人类阅读调试和程序快速序列化)。
重载时的两大注意事项:
安全性(状态不持久化):为了防止越权,像 trusted_tools(用户临时信任的敏感工具名单)这类授权状态不能存入 JSON。每次重新加载必须清空信任状态,由用户重新审核敏感操作。
兼容性(反序列化过滤):随着项目迭代,Session 类的字段可能会增加或删除。在读取旧的 JSON 数据时,必须通过 StoredSession.dataclass_fields .keys() 过滤掉旧文件里多余的废弃字段(防止 TypeError 崩溃),从而实现向后兼容(Backward Compatibility)。
问题 3:edit_file 的 Diff 预览和 write_file 的本质区别是什么?为什么这个区别重要?
本质区别:
write_file 是
"全量覆写"。无论文件多大,只要修改哪怕一个字母,大模型都必须把整个文件的几百行代码从头到尾重新生成一遍,然后全盘覆盖。
edit_file 是
"局部替换(str.replace)"**。它只需要大模型提供 old_str 和 new_str,定位到文件中的对应位置进行精准的局部替换。

为什么重要(Diff 预览的作用):

节省成本与时间:局部替换的 Token 消耗极低,输出速度极快,是工业级应用的基础。

降低幻觉风险:让模型重写 1000 行代码,极易在未修改的区域产生遗漏或拼写"幻觉"。

防呆与安全(Diff 的意义):我们在 edit_file 中引入了 count == 1 的唯一性检查机制。并在成功后通过控制台打印 Diff(前 5 行差异),让用户清晰可见地感知到"哪里被删了,哪里增加了",防止同名变量或代码块被误改,大幅提升了操作的透明度和安全性。

相关推荐
Dxy12393102161 小时前
Python设置Excel表格边框样式:从基础到高级
前端·python·excel
布列瑟农的星空2 小时前
tree shaking为什么失效
前端
idcu2 小时前
加入 Lyt.js:从第一行代码到核心贡献者
前端
肥羊zzz2 小时前
Vue2 vs Vue3 中 v-for 的 key 用法对比
前端·vue.js
dsyyyyy11013 小时前
HTML总结
前端·html
甄心爱学习3 小时前
【项目实训(个人5)】
python·github
前端那点事3 小时前
深度解析:Vue中computed的实现原理(易懂不晦涩)
前端·vue.js
Mike_jia3 小时前
PortNote:可视化端口管理工具,让端口冲突成为历史
前端
前端那点事3 小时前
Vue中深克隆的3种实现方案(附详细注释+测试)
前端·vue.js