你有没有遇到过这种情况:改一个 bug 要翻遍几个文件,写完脚本还要手动查文档,终端里同时开三个 tab,一个跑测试、一个查日志、一个翻文档。我自己折腾了一个叫free-claude-code的命令行工具,它把这些事全部收敛到同一个对话里:让它读代码、改文件、跑命令,最后汇总结果给你。这篇文章就把整个从零开发到日常使用的东西都交出来,包括架构设计、核心代码、踩坑记录。
free-claude-code是一个开源的、可自托管的终端编码助手,核心思路是“一个贴近自然语言的工具调度器”。它不绑定任何特定厂商,只要你有一个兼容标准接口的大模型服务,就能把“读文件、改文件、执行命令、搜索代码”这些高频操作全部交给它。无论你是厌倦了反复切换工具的独立开发者,还是想在团队里做一套统一编码助手的开发者,这篇文章都值得花十分钟看完。
1. 整体设计思路与项目规划
1.1 为什么我选择自建而不是直接使用现成的商业服务
最开始我也直接用过几家商业编码助手,体验确实好,但它们有几个让我不舒服的点:第一,费用不可控,按量计费在重度使用下很容易超预算;第二,数据要经过第三方服务,公司内部项目不方便直接扔上去;第三,工具行为是一个“黑盒”,我想让它支持内部脚本和私有工具,扩展起来很麻烦。
于是决定自建一个脚手架,目标不是复刻商业产品,而是做一个“够用、可扩展、本地优先”的终端代理。设计之初我给自己定了三条硬性指标:命令行启动后能连续对话;模型可以调用我本地的工具(读写文件、执行命令);所有配置都能通过一个配置文件修改,不写死在代码里。
free-claude-code这个命名也反映了我对它的定位:free 指的是开源自由,不是指一定要零成本运行;claude-code 这部分则是对“通用编码智能体”的指代。我更看重的是把“让模型操作终端”这个模式抽离出来,让大家能基于自己的模型和工具组合去复用。
1.2 技术选型:为什么用 Python 而不是 Node.js 或 Go
做 CLI 工具,Node.js 和 Go 都是热门选择,但我最后还是选了 Python 3.10+。原因很简单:团队和社区里的 AI 相关库大多数都是 Python 优先,后续接向量检索、接本地推理框架,Python 生态最顺;另一个原因是写原型快,一个周末就能跑通核心循环。
交互层上我用了prompt_toolkit,它能很好地支持多行输入、历史记录、自动补全。比起readline,它在 Windows 和 Linux 下的体验更一致,也不会出现输入中文时光标错位的问题。命令解析和参数校验用了 Typer,实际上就是一个基于类型注解的 CLI 框架,写起来比 argparse 舒服太多。
模型接入层我选择实现一套“兼容标准对话接口”的客户端,而不是绑定某个特定模型 SDK。这样无论是本地部署的开源模型,还是各种云端模型服务,只要它们提供chat/completions风格的接口,就能直接对接。这一点是后期最省心的地方,我测试模型时只需要改base_url和model_name,代码完全不用动。
1.3 整体架构:从用户输入到工具执行的全链路
整个系统分成四层:交互层、代理循环层、工具层、模型层。
交互层负责读取用户输入,展示流式输出,管理会话历史。代理循环层是核心,它维护一个messages数组,不断把用户意图、模型回应、工具执行结果填进去,再发给模型。工具层就是一组注册好的函数,模型一旦返回“调用某个工具”的指令,代理循环就去执行对应的函数。模型层负责把请求发出去,并把模型返回的工具调用指令解析出来。
用户输入 -> 交互层 -> 代理循环 -> 模型层 -> 返回响应 ↑ ↓(工具调用指令) 工具执行结果 <- 工具层这四层之间用 JSON 消息串起来,所以调试的时候可以直接打印messages数组,看看模型到底收到了什么。后面排查问题依赖这个能力,比想象中更重要。
2. 核心细节解析与实操要点
2.1 会话上下文管理的核心逻辑
编码助手和普通聊天机器人最大的区别在于上下文结构。每次对话不是简单的“你一句我一句”,而是要把“用户需求、代码文件内容、命令输出、模型分析”混在一起。我一开始只把用户输入和模型输出塞进messages,后来发现模型常常在第二轮就忘了前面让它改的是哪个文件。
所以free-claude-code把上下文拆成了两个部分:基础指令(System Prompt)和动态消息。基础指令里固定写清楚工具的用法、输出格式要求、安全约束;动态消息就是不断增长的用户请求、工具调用记录和执行结果。
消息管理这里有个容易忽视的细节:不要把所有工具结果原封不动塞回去。比如run_command执行一个make test可能输出几千行日志,如果不加截断,下一轮模型根本看不完全,而且 token 消耗会爆炸。我的做法是默认只保留每条命令输出的前 2000 字符,超出部分提示模型“输出已截断,请用更精确的命令重试”。
2.2 工具注册机制:让模型能“看见”能操作什么
模型本身不知道你能提供什么工具,它只知道你在 System Prompt 里写了什么。所以我设计了一个装饰器注册表,用函数签名自动生成工具描述。
import json import inspect TOOL_REGISTRY = {} def tool(name, description): def decorator(func): signature = inspect.signature(func) parameters = { "type": "object", "properties": {}, "required": [] } for param_name, param in signature.parameters.items(): param_type = "string" default = param.default if param.default is not inspect.Parameter.empty: if isinstance(default, bool): param_type = "boolean" elif isinstance(default, int): param_type = "integer" parameters["properties"][param_name] = {"type": param_type} if param.default is inspect.Parameter.empty: parameters["required"].append(param_name) TOOL_REGISTRY[name] = { "description": description, "parameters": parameters, "function": func } return func return decorator这段代码的作用是把read_file(path)、run_command(command)这类 Python 函数的签名转换成模型需要的 JSON Schema。模型响应里的工具调用请求会带上参数名和值,我只要在代理循环里匹配注册表里的名称,再把参数**kwargs传给函数即可。
这里实践上的一个坑是:工具的description写得越具体,模型的调用准确率越高。比如模糊地写“执行命令”,模型会在不确定时反复问你而非调用工具;但如果写清楚“执行 shell 命令,输出会被截断,超时默认为 30 秒”,模型的判断就果断很多。
2.3 工具调用循环:别让模型永远在“计划”而不动手
代理循环是整个项目里最容易写出 bug 的地方。最初的实现是一个while True,每次循环都调一次模型,直到模型返回普通文本。问题在于,某些参数化模型在没有明确“停止调用工具”限制时,会陷入“计划 -> 调用 -> 再计划 -> 再调用”的无限循环里,甚至明明已经完成任务还继续调用工具。
我的解决方案是引入“最大工具调用次数”和“观察结果注入”。每一轮工具调用结果会以role: "tool"的消息加入上下文,同时累计调用次数。当次数超过max_tool_calls(默认 8 次)时,强制让模型停止调用工具,直接输出当前状态摘要。这避免了预算失控,也反过来倒逼模型在有限的步骤里尽量高效。
另一个重要细节是,模型返回的普通文本也要在用户界面上流式显示。我用生成器逐个词输出,而不是等完整响应后再打印。这样用户体验会好很多,尤其是在长回答时,能看到实时输出而不是转圈等待。
2.4 模型接入:兼容标准chat/completions格式的客户端
我之前提到过不绑定 SDK,实际客户端代码非常简单,大概 40 行。它接收model、messages、tools、tool_choice这些参数,然后发到配置的 endpoint。这里的关键是:接口返回的finish_reason如果是tool_calls,就要把响应里的工具调用请求解析出来,而不是当成普通文本直接输出。
import httpx class ChatClient: def __init__(self, base_url, api_key, model): self.base_url = base_url.rstrip("/") self.api_key = api_key self.model = model def chat(self, messages, tools=None, tool_choice=None): url = f"{self.base_url}/chat/completions" headers = {"Authorization": f"Bearer {self.api_key}"} payload = {"model": self.model, "messages": messages} if tools: payload["tools"] = tools if tool_choice: payload["tool_choice"] = tool_choice with httpx.Client(timeout=60) as client: resp = client.post(url, headers=headers, json=payload) resp.raise_for_status() return resp.json()这个模块设计成只依赖httpx,方便测试时 mock。我在项目里加了一个mock_model.py,专门模拟返回虚假的工具调用命令,用来测试代理循环是否正确执行。没有这一步,调试真实模型时的成本会高到让人崩溃。
3. 完整实操过程与核心环节实现
3.1 环境初始化与目录结构
项目从空目录开始,我建议你保持以下结构:
free-claude-code/ ├── config.yaml ├── main.py ├── agent/ │ ├── __init__.py │ ├── core.py │ ├── tools.py │ └── client.py ├── tools/ │ ├── __init__.py │ ├── file_ops.py │ └── shell_ops.py └── requirements.txtconfig.yaml是全局配置的中心,它长这样:
model: base_url: "http://127.0.0.1:8000/v1" api_key: "local-key" model_name: "local-model" temperature: 0.1 agent: max_tool_calls: 8 max_output_chars: 2000 default_timeout: 30 confirm_before_exec: trueconfirm_before_exec建议保留为true,也就是每次模型要执行命令前,都让用户按一个y确认。虽然多了一步,但能挡住不少模型自说自话乱删文件的情况。我在真实使用中只关过一次,后来又赶紧打开了。
3.2 代理循环核心代码实现
代理循环是整个项目的大脑。下面这段是简化版,但保留了两个关键机制:工具调用轮转和超限停止。
def run_agent(agent, user_input): agent.messages.append({"role": "user", "content": user_input}) tool_count = 0 while tool_count < agent.max_tool_calls: response = agent.chat() if response["finish_reason"] != "tool_calls": agent.messages.append({ "role": "assistant", "content": response["message"]["content"] }) break tool_call = response["message"]["tool_calls"][0] tool_name = tool_call["function"]["name"] tool_args = json.loads(tool_call["function"]["arguments"]) if agent.confirm_before_exec and tool_name == "run_command": confirm = input(f"[执行] {tool_args},确认? [y/N] ") if confirm.lower() != "y": result = "用户取消了命令执行" else: result = execute_tool(tool_name, tool_args) else: result = execute_tool(tool_name, tool_args) agent.messages.append({ "role": "tool", "tool_call_id": tool_call["id"], "content": truncate(result, agent.max_output_chars) }) tool_count += 1 else: agent.messages.append({ "role": "user", "content": "[系统] 工具调用次数已用完,请直接给出阶段性总结。" }) agent.chat()这段代码里有个容易被忽略的地方:tool_call["id"]很重要,模型返回的工具调用消息会带一个唯一 ID,后续工具结果必须通过tool_call_id关联回去。如果不带这个 id,很多模型会报错或直接忽略结果。
3.3 工具函数实现:读文件、写文件、执行命令
工具层我实现了五个常用操作:读文件、写文件、列出目录、执行命令、文本搜索。以读文件为例,关键点是支持offset和limit,避免一次性读整个大文件,让模型学会“分段读取”。
@tool("read_file", "读取文本文件,按行范围返回内容。") def read_file(path: str, offset: int = 0, limit: int = 100): with open(path, "r", encoding="utf-8") as f: lines = f.readlines() return "".join(lines[offset:offset + limit])这里的一个技巧是:默认offset是 0,模型如果在代码里发现要修改的位置在第 200 行,第二次调用会带上{"offset": 200, "limit": 100}。这样每次读文件的 token 开销都很小,模型也能拿到准确的目标代码段。
执行命令的时候我用了subprocess,并且强制加上了超时。因为一个模型热启动时经常会产生“先休眠一下”的奇怪念头,如果命令是sleep 1000,没有超时整个代理就会卡死。超时默认 30 秒,命令结束时如果返回非零退出码,我会把stderr拼到结果里,模型才能知道报错信息。
3.4 参数调优与几个真实任务的验证
核心循环跑通后,我用三个任务做了一次冒烟测试:
第一个任务是“修改某个 Python 文件里的函数名,并同步修改所有调用点”。模型会先搜索哪些文件引用了旧函数名,再用sed或者write_file替换。实测在代码量 2000 行以内时,工具调用次数 3-5 次就能完成。
第二个任务是“跑测试并修复失败用例”。模型需要执行pytest,然后根据报错信息定位文件、修改代码、再跑一遍。这一步经常遇到的是模型在读取错误信息后,把错误原因理解错了。我通过调高temperature到 0.1,明显减少了这种“自信拍脑袋”的行为。
第三个任务是“把某个目录下的所有 markdown 文件里标题层级统一”。模型会通过list_dir找到.md文件,再逐个read_file检查标题,最后write_file修改。这个任务暴露了上下文问题:当文件很多时,模型容易漏掉一部分。解决方法是在 User Prompt 里加上一句“请先列出所有目标文件再逐一处理”。
这三个任务验证下来,系统算是达到可日常使用的水平。当然,它离商业产品还有距离,但作为一个自托管工具,已经能解决我八成以上的终端编码场景。
4. 常见问题排查与实操避坑
4.1 模型陷入工具调用死循环
这是最经典的问题,表现是模型一直在调用工具,每次调用的参数都差不多,不输出普通文本。排查方式有两个:先用max_tool_calls限制兜底,再定位是不是工具描述写得不清楚导致模型无法判断完成条件。
我遇到过一个具体场景:让模型“统计项目代码行数”,它反复调用run_command执行find . | xargs wc -l,但每次拿到的输出都是“输出太长已截断”,所以它一直重试。后面我改成让模型只执行一次,如果截断就换个更精确的命令。同时我加了一条 System Prompt:“如果一个命令输出过长,请换更精确的命令,不要重复相同命令。”
4.2 模型上下文膨胀导致指令丢失
当对话进行到二三十轮后,模型开始忽略早先的工具结果,甚至会出现“重新清空文件”这种危险操作。我这边收集到的典型例子是:模型在第五轮修改了一个文件,到第十二轮又忘掉了,直接根据新的输入覆盖了文件内容。
效率最高的办法不是无限扩大上下文,而是做一个“关键事实压缩层”。每完成一个工具任务,我就提取一句摘要放进一个独立的summary字段,下次发请求时挂到 System Prompt 后面。比如“已完成的修改:将parse_config函数重命名为load_config,并更新所有引用”。这个摘要通常不超过 200 字符,但比保留一堆历史记录有用得多。
4.3 工具权限过宽导致误操作
如果让模型自由执行rm、mv等命令,风险很大。有一次模型为了清理临时文件,执行了rm -rf build/,我本来没打算现在就清理,结果整个构建目录没了。好在源码目录还在,但足以让我下定决心加权限控制。
我的方案是命令前缀白名单:默认只放行git、python、pytest、ls、cat、grep等安全命令,遇到rm、mv、dd等需要交互确认。如果你用的模型能力足够强,还可以在工具描述里写上“删除文件前必须调用list_dir确认路径”。不过最稳妥的还是“确认模式常开,不然后悔莫及”。
4.4 输出截断导致的隐性失败
工具执行结果截断后,模型仍然返回“所有操作完成”,但实际上它并没有看到完整输出,可能漏掉错误。比如pytest输出 200 行,截断后只保留了前 50 行,尾部往往才是关键的错误栈。模型却会基于不完整信息下结论。
解决方案是我在截断文本前面加了一个醒目标记:
[TRUNCATED] 命令输出超过 2000 字符,已截断。若需查看完整输出,请使用 tail/head 或重定向到文件再读取。同时,我还加了一个read_large_output工具,让模型可以主动读取已经被写进临时文件里的完整输出。这个组合拳解决了很多看似“模型智力不足”的问题。
4.5 连接本地模型时的性能与稳定性问题
如果你像我喜欢折腾本地模型,大概率会遇到生成速度慢、偶发超时。特别是用 CPU 推理时,一段代码修改请求可能要等一两分钟,模型还会因为超时中断而“断片”。
我这里的建议是:连接本地模型时,把 HTTP 客户端超时调到 120 秒,并且开启流式接收,至少让界面持续有内容输出。另外,别在 System Prompt 里塞太多历史摘要,本地模型对长上下文的处理能力弱,能精简就精简。
| 常见问题 | 表现 | 解决方案 |
|---|---|---|
| 工具调用死循环 | 模型反复执行相同工具 | 设置最大工具调用次数;优化工具描述 |
| 上下文膨胀 | 模型遗忘之前修改 | 手动维护关键事实摘要 |
| 权限误操作 | 模型执行危险命令 | 开启确认模式;命令白名单 |
| 输出截断 | 模型基于不完整信息判断 | 明确截断标记;提供读完整输出工具 |
| 本地模型超时 | 长时间无响应 | 调大超时;开流式输出 |
5. 一些值得记住的实战经验与扩展方向
项目做到现在,最让我意外的是“工具设计”比“模型能力”更影响最终效果。同一个模型,在工具描述模糊时的判断力和描述清晰时完全是两个档次。我后来把每个工具说明都按照“功能用途、适用场景、参数含义、常见错误”四个维度重写了一遍,模型的工具调用准确率提升非常明显。
如果你也想在自己的项目里复刻这套思路,可以先从最小版本开始:只实现读文件、写文件、执行命令三个工具,配合一个本地模型跑通循环。不要一上来就加代码搜索、远程调用这些高级功能,否则你会被一堆边缘情况淹没。
后续我计划扩展的方向有三个:一是支持多轮并行工具调用,模型可以一次返回多个工具指令,同时处理多个文件;二是增加项目级索引,让模型可以直接搜索语义而不是只能 grep;三是把确认模式做成更细粒度的规则,比如某些目录只读、某些命令黑名单。这些做出来之后,free-claude-code才更像一个成熟的编码代理。
最后分享一个小技巧:如果你和我一样经常在多个终端切换,建议把配置文件的路径写成环境变量,比如FCC_CONFIG=/path/to/config.yaml。这样每个项目都能有自己的模型配置和工具策略,同一个free-claude-code二进制,换环境变量就是换一套专属助手。这个习惯让我的项目适配成本低了很多,也算是我折腾这么久最值得的收获。