
新到一台机器最烦的不是写代码是配环境。装 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 里改配环境逻辑就是改一个函数跑一次就见效。这种轻量感是其他工程结构给不了的。第二零依赖。只要机器上有bash、curl、python3就能跑。用 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_asciiFalse)) $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 死循环烧钱。每一轮做四件事把 messages 数组和工具清单打包成请求体curl 发给模型解析返回的 message追加到 messages如果 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_asciiFalse)) ) 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检查命令是否存在及路径cmdrun_command执行命令返回退出码和输出片段cmd,timeoutread_file读取文本文件内容pathwrite_file写文件自动备份原文件path,contentask_user向用户请求确认或输入questionenv_diff对比当前环境变量与目标列表keys不要把工具设计得太细碎也不要让它太黑盒。我的经验是工具粒度应该对应一个可校验的动作。比如install_package看着方便但它隐藏了包管理器的差异出错后不好排查不如保留run_command让 LLM 自己决定调用apt-get还是brew。这样模型对每一步都有明确的预期脚本出问题时用户也能直接定位到具体命令。3.3 一个完整案例macOS 上配 python3.12 poetry光说理论太抽象给你看一次真实运行 d.sh 的过程。我在一台新 MacBookApple Silicon、装过 Homebrew上执行./d.sh 帮我配置 python3.12 和 poetry 环境Agent 第一轮输出的是侦察结果当前状态 - 系统macOS (arm64) - 包管理器brew - shellzsh - python3 存在但版本是 3.9不是 3.12 - python3.12 不存在 - poetry 不存在 目标状态 - python3.12 可用 - poetry 可用 执行计划 1. brew install python3.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 里的明确指令和最小工具集来保证的。安全闸门分三层计划确认执行前展示完整计划用户确认后才动手命令可见每条命令执行前都会实时回显用户中途可以 CtrlC危险命令防护run_command里检查rm -rf /、mkfs、dd 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 -c的json模块。你可以在 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 21 | 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_asciiFalse)) $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 的subprocesstimeout实现同样的效果。这个交叉平台坑在配环境脚本里尤其常见遇到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 就是新 Agentd.sh 最让我意外的好处是它不是一个固定的脚本而是一套可随意改造的模板。想让它变成代码审查 Agent把工具集换成run_commandgit diffprompt 里说明分析变更并给出评审意见就行。日志排查 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_system和check_command这类侦察工具生成一份完整的配置计划并打印出来然后退出绝不执行任何写操作。为什么特意留这个模式因为 Agent 第一次跑的时候你其实不知道它会怎么理解你的需求。先用--plan-only跑一遍看它规划的命令你是否认同再决定要不要真正执行。这就像写代码之前先做 review成本低、风险小还能让模型输出变得更符合直觉。d.sh 这个项目从一开始的把 Agent 塞进 bash 文件的执念最终落地成了一个可靠、透明、还有余力的配环境助手。环境配置这件事没有银弹但把 Agent 装进一个 bash 文件至少让信任这件事变得简单了。