用bash文件打造零依赖Coding Agent:配环境自动化实战
2026/9/17 4:11:35 网站建设 项目流程

新到一台机器,最烦的不是写代码,是配环境。装 Python、换源、配 shell、装数据库、调 PATH……每一步都可能翻车,网上每一条教程都默认你和我用的是同一个版本。前前后后我试过好几套 Coding Agent 框架,最后全被框架自己的依赖链卡住:要跑 Agent,先得配环境;而我恰恰需要 Agent 来帮我配环境。后来我想通了一件事——干脆把整个 Coding Agent 塞进一个 bash 文件里,做一个专职的配环境 Agent。这就是 d.sh,一个单文件脚本,在任何有 bash 的机器上都能跑,让 LLM 决定配什么、bash 负责可靠地把它装上。这篇文章把它的架构、工作流、还有一路踩过的坑完整拆开讲。

1. 为什么非要把 Agent 塞进一个 bash 文件——动机与边界

1.1 配环境是个悖论:越需要它的机器越装不了它

配环境这件事有个很反直觉的地方:问题最严重的机器,往往是最缺工具链的机器。新买的 MacBook、刚启动的云主机、还有那些被折腾得 PATH 混乱的 Linux 开发机,它们连git可能都没有,更别提 Python 3.10+、npm、Docker 这一整套现代 Agent 框架的标配了。

我最初尝试用现成的 Coding Agent 开源项目来管理环境,结果发现它们普遍要求:

  • Python 3.10 以上,外加十来个 pip 依赖;
  • 有一半项目需要 Node.js 运行时;
  • 某些项目还要求 Docker 或者独立的模型运行时。

也就是说,为了让它帮你配python3.12,你得先手动配好一个能跑 Python Agent 的环境。这个循环到第三周的时候,我彻底烦了。我当时对着终端里那一长串报错想:我需要的不是一个框架,不是一个平台,需要的是一个能直接执行的脚本。这个脚本最好只有几百行,能看懂每一行在干什么,还能让我随时改成我想要的样子。

1.2 单文件 bash 的三重价值:可追踪、零依赖、透明

把 Agent 做成单个.sh文件,不是极简主义洁癖,而是三个非常实际的价值。

第一,可追踪。一个文件就是全部逻辑。改了什么、删了什么、是谁改的,用git diff一眼就能看明白。我之前维护过各种分布式的 Agent 工程,改一个工具函数要跨三个目录,验证一次要靠跑完整套测试。在 d.sh 里,改配环境逻辑就是改一个函数,跑一次就见效。这种轻量感是其他工程结构给不了的。

第二,零依赖。只要机器上有bashcurlpython3,就能跑。用 Linux 的机器基本自带,macOS 装完 CommandLineTools 也有 python3。这三样东西,本身就是配环境最基础的原料。d.sh 不引入任何框架依赖,自然也就不会有"框架需要环境、环境需要框架"的悖论。

第三,透明。Agent 最大的问题不是能力,是信任。一个黑盒帮你执行了 20 条命令,你敢不敢让它跑?d.sh 的执行原则是"每条命令先打印,再执行;关键步骤要确认,结果有校验"。LLM 只负责规划,bash 负责执行,每个决策过程都在终端里看得见。这种透明性是 Agent 落地到真实机器上的前提,不是可有可无的加分项。

1.3 边界:bash Agent 不适合什么场景

说实话,这个方案不是万能的。我在设计 d.sh 之初就给自己定了四条边界,防止它被滥用成一个四不像:

  • 不适合超长会话:bash 脚本不是聊天应用,没有持久记忆库,复杂对话状态很难维护;
  • 不适合高并发:curl阻塞模型,一次只能处理一个任务;
  • 不适合非结构化大数据:日志分析、向量检索之类的活儿,交给专门的数据工具;
  • 不适合 GUI 交互:它对标的是终端原生工作流。

配环境恰好落在适合区间里。环境配置的本质是命令序列加条件判断,这是 bash 最擅长的事。LLM 负责理解意图和做决策,bash 负责把命令可靠地执行掉,两者是天然的互补关系。认清边界以后,这个切入角度就变得非常自然。

2. d.sh 的骨架是怎么转起来的——LLM 对话层与工具执行循环

2.1 用 curl 和 python3 搭一个 JSON 对话层

一个 Agent 的骨架没有多玄妙,本质上就是:把 LLM 当成一个会说话的决策器,把你的终端命令封装成它能调用的工具,然后循环调用,直到它给出最终结论。

先看最基础的对话层,也就是怎么跟 LLM 通信。d.sh 用curl向 OpenAI-compatible 接口发请求,兼容各类兼容网关;模型名和环境变量都可以在脚本开头覆盖。

#!/usr/bin/env bash # d.sh —— 一个配环境 Agent # 用法: ./d.sh "帮我配一个 python3.12 环境" set -uo pipefail API_ENDPOINT="${API_ENDPOINT:-https://api.openai.com/v1/chat/completions}" MODEL="${MODEL:-gpt-4o-mini}" API_KEY="${OPENAI_API_KEY:-}" # 发送一轮请求,并返回 messages 数组(JSON) chat_once() { local payload="$1" curl -s --max-time 300 --connect-timeout 10 \ "$API_ENDPOINT" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d "$payload" }

这里有个容易犯的错误:在 bash 里手工拼 JSON 字符串。千万不要这么做。命令输出里的引号、换行、反斜杠、中文全都能把字符串破坏得七零八落。我的做法是所有需要构造或解析 JSON 的地方,全部交给python3标准库里的json模块处理。

json_append() { local arr="$1" item="$2" python3 -c ' import json, sys arr = json.loads(sys.argv[1]) item = json.loads(sys.argv[2]) arr.append(item) print(json.dumps(arr, ensure_ascii=False)) ' "$arr" "$item" }

你可能想问:为什么不直接用jq?我的理由是jq也是额外依赖,而且python3处理二进制输出、Unicode 字符更稳,在大多数新系统上默认就有。用python3做 JSON 管道,一次解决序列化和转义,省心。

2.2 工具注册与 function calling 的 bash 姿势

光能对话没用,它得能动手。所以 d.sh 定义了一套自己的工具,然后利用 function calling 能力,把这些工具暴露给 LLM。工具越多,它能做的事越复杂,但针对配环境这个场景,五个工具就够用了。

工具注册是一段 JSON 描述,发给 LLM,让它知道每个工具是干什么的、参数是什么:

[ { "type": "function", "function": { "name": "detect_system", "description": "探测当前系统信息、CPU架构、可用的包管理器", "parameters": {"type": "object", "properties": {}, "required": []} } }, { "type": "function", "function": { "name": "check_command", "description": "检查指定命令是否存在并返回路径", "parameters": { "type": "object", "properties": {"cmd": {"type": "string", "description": "命令名"}}, "required": ["cmd"] } } }, { "type": "function", "function": { "name": "run_command", "description": "执行一段 shell 命令,返回退出码和输出(截断后)", "parameters": { "type": "object", "properties": { "cmd": {"type": "string"}, "timeout": {"type": "integer", "description": "超时秒数", "default": 120} }, "required": ["cmd"] } } } ]

LLM 收到这份工具清单后,有两种回应方式:

  • 返回普通文本,表示任务完成,这时 d.sh 输出内容并退出;
  • 返回一个tool_calls数组,里面是它想调用的函数和参数,d.sh 执行这些函数,把结果回传给它,继续循环。

接下来需要一个分发函数,把 JSON 参数翻译成 bash 函数调用。这里仍然交给 python3 解参数,减少转义问题:

dispatch_tool() { local name="$1" args="$2" case "$name" in detect_system) detect_system ;; check_command) local cmd cmd=$(python3 -c 'import sys, json; print(json.loads(sys.argv[1])["cmd"])' "$args") check_command "$cmd" ;; run_command) local cmd timeout cmd=$(python3 -c 'import sys, json; print(json.loads(sys.argv[1]).get("cmd", ""))' "$args") timeout=$(python3 -c 'import sys, json; print(json.loads(sys.argv[1]).get("timeout", 120))' "$args") run_command "$cmd" "$timeout" ;; *) echo "{\"error\": \"unknown tool: $name\"}" ;; esac }

2.3 主循环:不是 chat,是 while + curl + case

整个 Agent 的主体是一个while循环,最多跑 20 轮,防止 LLM 死循环烧钱。每一轮做四件事:

  1. 把 messages 数组和工具清单打包成请求体;
  2. curl 发给模型;
  3. 解析返回的 message,追加到 messages;
  4. 如果 message 里有tool_calls,逐个 dispatch 并回填结果,继续循环;如果没有,打印最终回复,退出。
for _ in $(seq 1 20); do # 构造请求体 payload=$(python3 - <<PY import json, os messages = json.loads(os.environ["MESSAGES"]) tools = json.loads(os.environ["TOOLS_JSON"]) payload = { "model": os.environ["MODEL"], "messages": messages, "tools": tools, "tool_choice": "auto", } print(json.dumps(payload)) PY ) # 发请求 resp=$(chat_once "$payload") # 提取 message 并追加 msg=$(echo "$resp" | python3 -c ' import sys, json d = json.load(sys.stdin) print(json.dumps(d["choices"][0]["message"], ensure_ascii=False)) ') MESSAGES=$(json_append "$MESSAGES" "$msg") # 看看有没有工具调用 if echo "$msg" | grep -q '"tool_calls"'; then echo "$msg" | python3 -c ' import sys, json msg = json.load(sys.stdin) for call in msg.get("tool_calls", []): print(json.dumps({"id": call["id"], "name": call["function"]["name"], "arguments": call["function"]["arguments"]})) ' | while read -r call_json; do id=$(python3 -c 'import sys, json; print(json.loads(sys.argv[1])["id"])' "$call_json") name=$(python3 -c 'import sys, json; print(json.loads(sys.argv[1])["name"])' "$call_json") args=$(python3 -c 'import sys, json; print(json.loads(sys.argv[1])["arguments"])' "$call_json") result=$(dispatch_tool "$name" "$args") MESSAGES=$(json_append_tool_result "$MESSAGES" "$id" "$result") done else # 没有工具调用,输出最终回复 echo "$msg" | python3 -c 'import sys, json; print(json.load(sys.stdin).get("content", ""))' break fi done

如果轮数用完了,脚本会打印一条提示让用户检查是模型在绕圈子还是工具有问题。这个结构很简单,但很可靠,我后来扩展其他 Agent 时基本沿用这套骨架。

3. 配环境 Agent 的核心工作流:感知、计划、执行、校验

3.1 把配环境看成一次"状态迁移"

配环境在我心里不是"执行一堆命令",而是做一次状态迁移:从当前状态S0迁移到目标状态S_target。Agent 的价值在于:能自动感知S0,能理解用户描述的S_target,能规划出迁移路径,并且验证确实到达了S_target

在这个框架下,d.sh 的 system prompt 可以这样写:

你是一个配环境 Agent。你的任务是把当前系统配置成用户描述的目标状态。 请严格按以下流程工作: 1. 先使用 detect_system 探测操作系统、架构、包管理器; 2. 使用 check_command 检查目标涉及的关键命令是否已存在,判断差距; 3. 基于差距生成一份有序的执行计划; 4. 执行前把计划用简洁列表展示给用户,并调用 ask_user 确认; 5. 执行每一步时调用 run_command,注意退出码; 6. 每完成一个步骤,用 check_command 或可观察的结果进行校验; 7. 全部完成后,用自然语言报告结果和后续注意事项。

这段 prompt 决定了 Agent 的工作节拍。它不会一上来就brew install,而是先做侦察,再给计划,这既是工程习惯,也是安全习惯。

3.2 工具集怎么写才够用

配环境场景里,d.sh 的工具集被我精简到七到八个,每个都只做一件事:

工具作用关键参数
detect_system探测系统信息、包管理器
check_command检查命令是否存在及路径cmd
run_command执行命令,返回退出码和输出片段cmd,timeout
read_file读取文本文件内容path
write_file写文件,自动备份原文件path,content
ask_user向用户请求确认或输入question
env_diff对比当前环境变量与目标列表keys

不要把工具设计得太细碎,也不要让它太黑盒。我的经验是:工具粒度应该对应"一个可校验的动作"。比如install_package看着方便,但它隐藏了包管理器的差异,出错后不好排查;不如保留run_command,让 LLM 自己决定调用apt-get还是brew。这样模型对每一步都有明确的预期,脚本出问题时,用户也能直接定位到具体命令。

3.3 一个完整案例:macOS 上配 python3.12 + poetry

光说理论太抽象,给你看一次真实运行 d.sh 的过程。我在一台新 MacBook(Apple Silicon、装过 Homebrew)上执行:

./d.sh "帮我配置 python3.12 和 poetry 环境"

Agent 第一轮输出的是侦察结果:

当前状态: - 系统:macOS (arm64) - 包管理器:brew - shell:zsh - python3 存在,但版本是 3.9(不是 3.12) - python3.12 不存在 - poetry 不存在 目标状态: - python3.12 可用 - poetry 可用 执行计划: 1. brew install python@3.12 2. 安装 poetry(官方安装脚本) 3. 配置 PATH:将 ~/.local/bin 加入 ~/.zshrc 4. 验证 python3.12 --version 和 poetry --version

然后它问我:"是否按此计划执行?(y/N)"。

我确认后,它逐步执行。每一步都把命令打出来,然后输出退出码和结果片段。安装 poetry 那一步,它用的是官方安装脚本;配置 PATH 时,它先read_file ~/.zshrc检查是否已有相关行,再决定是否真的需要追加内容,而不是无脑往里塞。最后验证:

✅ python3.12 --version -> Python 3.12.4 ✅ poetry --version -> Poetry version 1.8.3

注意它所有步骤都在用户会话权限内执行,没有偷偷sudo,路径也只动了~/.zshrc和用户目录下的安装目录。这就是前面说的"状态迁移"思想:先探明当前状态,再确定差距,路径就清晰了。

3.4 幂等性与安全闸门

环境配置最容易被忽略的是幂等性。同样一句"帮我配一下 Python",第二次运行时,系统已经不缺 python3.12 了,Agent 不能傻乎乎地再装一遍。d.sh 的解法是让check_command永远先于run_command:模型在生成计划前必须先探测当前状态,如果探测显示目标已达到,就直接结束,不再执行命令。这个约束不是靠 model 自觉,而是靠 system prompt 里的明确指令和最小工具集来保证的。

安全闸门分三层:

  • 计划确认:执行前展示完整计划,用户确认后才动手;
  • 命令可见:每条命令执行前都会实时回显,用户中途可以 Ctrl+C;
  • 危险命令防护run_command里检查rm -rf /mkfsdd if=等黑名单模式,命中就拒绝执行。

这套安全设计花的心思不多,但给我省了很多麻烦。有一次 Agent 在清理临时文件时差点生成rm -rf $HOME/test /tmp,因为命令里多了一个空格,黑名单没拦住,但执行前确认环节让我发现并取消了它。

4. 实测中踩过的坑:JSON 转义、超时、sudo 与幂等性

4.1 JSON 转义地狱,以及为什么永远别手动拼 JSON

我第一个版本里,给 LLM 回传命令结果时,直接用"output": "$(command)"的方式拼字符串。第一次跑ls -la a\ b就把整个 JSON 炸了——输出里有引号、有换行、有反斜杠,bash 把这一切全盘塞进字符串,最终模型收到的是 500 行解析失败。

后来我发现网上很多 bash 写 AI 工具的帖子都警告过这个问题,但真正根治它的唯一办法是:所有 JSON 构造和解析都必须经过python3 -cjson模块。你可以在 bash 里构造参数,但参数一旦进入 JSON 层,就先json.dumps再组装;组装后如果想往里拼接任何内容,再用json.loads+ 追加 +json.dumps,而不是字符串插值。这个习惯我过了很久才彻底养成,改完之后脚本稳定性提升了一个数量级。

4.2 curl 超时、重试与上下文截断

LLM 接口不是本地函数,响应可能 30 秒,也可能 3 分钟。最早的版本没有设置超时,Agent 在模型偶发抽风时能挂一个下午。现在脚本固定用--max-time 300 --connect-timeout 10,并且在遇到 429/5xx 时做三次重试,每次隔 5 秒。

另一个坑是上下文爆炸。run_command的输出如果全量返回,很容易把几千行日志灌进 messages,几个来回之后 token 消耗就失控了。我在工具里做了一个截断:

run_command() { local cmd="$1" timeout="${2:-120}" out code out=$(timeout "$timeout" bash -c "$cmd" 2>&1 | head -c 4000) || true code=$? python3 -c ' import json, sys out = sys.stdin.read() result = {"exit_code": %d, "output": out, "truncated": len(out) >= 4000} print(json.dumps(result, ensure_ascii=False)) ' "$code" <<< "$out" }

这个head -c 4000很重要。它保证了无论命令输出多长,回传给模型的都只有前 4000 个字符,并在结果里标注了 truncated。模型收到标识之后,如果还需要更多信息,会主动要求看某个特定片段,我也因此加了read_file的后半段支持。既省钱,又让 output 的意义更明确。

4.3 sudo、权限与危险命令白名单

Agent 配环境时经常会遇到需要管理员权限的场景:写/etc/hosts、装系统级包、改 shell 文件。我明确禁止 d.sh 去缓存或者猜 sudo 密码,更不存在把密码写进脚本里的操作。遇到需要权限的命令,它走ask_user询问用户,由用户在终端手动输入密码,或者告知用户这条命令需要他们自己在另一个窗口以管理员身份执行。

这里补一个细节:timeout命令在 macOS 上默认不存在,Linux 的 coreutils 里有。我在脚本里做了个兼容方案:优先使用timeout,如果不存在则用 Python 的subprocess+timeout实现同样的效果。这个交叉平台坑在配环境脚本里尤其常见,遇到command not found: timeout的时候,很多人会误以为是自己机器缺了某个包,其实只是 macOS 的命名差异。

4.4 脚本自身的坑:CRLF、bash 版本与 set -e

第一次写的时候我把脚本从 Windows 仓库拉下来直接跑,终端直接报/bin/bash^M: bad interpreter: no such file or directory。这是因为脚本文件是 CRLF 换行,bash 把^M当成了命令的一部分。解决方法是先做一次换行转换:sed -i 's/\r$//' d.sh

另外,macOS 自带的 bash 还是 3.2,不支持关联数组等 bash 4 的特性。所以 d.sh 的代码刻意避开了这些语法,只用 POSIX 兼容的子集加上简单的数组操作。如果你手头的环境是 bash 4+,代码可以更漂亮,但为了在多平台通用,克制一点是值得的。

最后是set -euo pipefail。这个选项在普通脚本里是救命的,但在 Agent 里反而会误伤。工具的某条命令失败了,你不能让整个脚本崩溃——Agent 得把错误信息回传给 LLM,让它重新规划。所以 d.sh 里禁用set -e,只用set -uo pipefail,所有工具函数的退出码都显式捕获。这个取舍我一开始没想清楚,被脚本中途退出的 bug 折磨了很久。

5. 从配环境 Agent 到"万物 Agent":单文件架构的扩展空间

5.1 换一份 system prompt 就是新 Agent

d.sh 最让我意外的好处是:它不是一个固定的脚本,而是一套可随意改造的模板。想让它变成代码审查 Agent?把工具集换成run_command+git diff,prompt 里说明"分析变更并给出评审意见"就行。日志排查 Agent?加一个grep_logs工具,prompt 改成"定位报错并给出修复建议"。

因为工具都是 bash 函数,扩展开销极低。新加一个工具,核心就三步:写一个 bash 函数,在TOOLS_JSON里加描述,在dispatch_tool里加一个 case 分支。整个过程十分钟内搞定。相比改一个框架级 Agent,这个成本几乎可以忽略。

5.2 单文件 Agent 与 CI/CD 的组合

我还试过把 d.sh 塞进 Docker 镜像,在 CI 里跑。因为它是单文件,镜像打包非常简单,只需要包含 bash、curl、python3 和一个只读挂载的源码目录。跑一次 d.sh 来生成项目脚手架、补全依赖清单,甚至自动改配置,都很稳。相比那种需要先装一套 Agent SDK 的 CI 方案,这个路径清晰得多。

有个实际问题值得提醒:CI 环境通常是交互受限的,ask_user这种工具在 CI 里必须能自动跳过。我在 d.sh 里加了一个--yes参数,强制确认所有步骤,这样就能在无人值守的 CI 流程里跑了。当然,危险命令黑名单在这种情况下仍然生效。

5.3 沉淀计划模板:让 Agent 越用越顺手

配环境有一类重复需求:配 Python、配 Node、配前端项目、配数据库驱动。每次让模型从零开始想步骤其实是一种浪费,而且不同模型对同一需求的默认行为差异很大。我开始在仓库里维护一个plans/目录,把验证过的配环境计划存成 markdown 模板,d.sh 启动时先让模型读一下相关模板,再基于模板生成执行计划。这样不仅步骤更稳定,模型的规划成本也显著下降了。

大多数模板其实就是三部分:预检命令、安装命令、校验命令。比如配 Node 环境的模板,核心就是node -v/npm -v预检、用 nvm 或 fnm 安装、再node -v校验。模板不是限定,而是让模型的输出更贴近团队的实际习惯,避免它给你装一个没人用过的全局工具。

5.4 个人建议:永远保留 plan-only 模式

到最后分享一个我自己的小执念:d.sh 里永远保留一个--plan-only模式。这个模式下,Agent 只会运行detect_systemcheck_command这类侦察工具,生成一份完整的配置计划并打印出来,然后退出,绝不执行任何写操作。

为什么特意留这个模式?因为 Agent 第一次跑的时候,你其实不知道它会怎么理解你的需求。先用--plan-only跑一遍,看它规划的命令你是否认同,再决定要不要真正执行。这就像写代码之前先做 review,成本低、风险小,还能让模型输出变得更符合直觉。

d.sh 这个项目从一开始的"把 Agent 塞进 bash 文件"的执念,最终落地成了一个可靠、透明、还有余力的配环境助手。环境配置这件事没有银弹,但把 Agent 装进一个 bash 文件,至少让信任这件事变得简单了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询