title: 用 Rust 写了一个行式 coding agent date: 2026-09-26 用了一年多 Claude Code ,从 本地配合 DeepSeek 跑生信流程 ,到 在曙光服务器上公共部署 ,越用越觉得这类工具没必要那么复杂。模型 API 说白了就是一个 HTTP 请求加一个 SSE 流,工具无非是读写文件和跑命令,交互界面就是个终端,连 TUI 都不用做,普通的行式会话就够。
抱着这个想法,最近用 Rust 写了一个自己的 agent ,就叫 llm ,一个静态链接的二进制文件,没有任何运行时依赖,扔到机器上就能跑。和之前 vibe coding 博客框架 是同一个思路,核心只做必须做的事,其余的全部留出扩展的缝,这样出问题也知道去哪修。
安装 Linux 和 macOS 下面一行命令,下载预编译二进制,校验 sha256 之后装进 ~/.local/bin ,全程不需要 root curl -fsSL /ai-market-guide/ | sh PATH 里没有这个目录的话安装脚本会顺手加上。重复执行同一条命令就是更新器,版本有变化会打印 updating 0.2.1 -> 0.2.2 ,没变化就不动, LLM_VERSION 可以固定某个 release , LLM_REPO 可以从 fork 装, LLM_FORCE=1 强制重装。
Windows 下面从 PowerShell 做同样的事 irm /ai-market-guide/ | iex Linux 用的是静态 musl 构建,同一个二进制在任何发行版上都能跑,目前提供 x86_64 和 aarch64 的 Linux ,x86_64 和 aarch64 的 macOS ,还有 x86_64 的 Windows 。之前在曙光服务器上部署 Claude Code 的时候,CentOS 7 的 glibc 老得连 conda 都得搬出来兜底,现在这个 musl 二进制 scp 过去 chmod +x 直接就能用,省心多了。
想从源码编译也很简单,装好 Rust 工具链以后 git clone /ai-market-guide/ cd llm cargo build --release 二进制在 target/release/llm 。所有状态都放在 ~/.llm 下面,设置 LLM_USER_PATH 可以把整个目录挪走,测试的时候特别有用。
登录和模型配置 第一次用进去敲 /login ,选一个 provider ,粘贴 API key ,输入是隐藏的,然后从 provider 的实时模型列表里挑一个默认模型,就完事了。目录里内置了 38 个 provider 的接入配置,Anthropic 、OpenAI 、DeepSeek 、Google 、Groq 、Mistral 、xAI 、OpenRouter 这些都有,Ollama 、LM Studio 、llama.cpp 、vLLM 一类的本地运行时也在。
esc 随时取消,不会写入任何东西。我日常挂的是 DeepSeek 。
喜欢手动编辑配置的话,向 ~/.llm/config.json 里写一个块就行 { "providers": { "deepseek": { "kind": "openai-compat", "base_url": "/ai-market-guide/ "api_key": "${DEEPSEEK_API_KEY}", "models": ["deepseek-chat", "deepseek-reasoner"] } } } kind 只有两种, openai-compat 和 anthropic 。 api_key 可以直接写 key ,也可以写 ${环境变量名} ,请求的时候才去环境里读,key 不用落在配置文件里。
同一个 provider 挂两个账号就 /login 两次,第二个会建议叫 NAME-2 ,之后模型用 provider/model 的完整 id 去挑,两边都提供的裸模型名会被当作歧义拒绝,而不是按顺序瞎猜一个。模型在会话里用 /model 换,列表是实时拉的,你保存过的固定在最上面, /thinking 单独调思考深度。
-m 和环境变量 LLM_MODEL 可以按次覆盖。基本用法 最短就三种 llm # 交互式会话 llm "fix the failing test" # 跑一个任务然后退出 cat error.log | llm "what broke?
提示词从管道来 管道输出是纯文本,没有颜色没有控制字符,直接重定向到文件或者接别的命令都行。交互会话里该有的细节都有。
任务跑着的时候随便打字,敲进去的内容会排队,在下一个工具调用的边界送达,相当于边跑边补充指示,一轮结束还没消费掉的自动变成你的下一条消息。 ctrl+c 或者 esc 中断当前任务。
! cmd 直接跑 shell 命令,tab 补全命令名和路径。
ctrl+v 把剪贴板里的图片直接贴成附件,剪贴板里没有图片的话会告诉你它装的到底是什么。粘贴超过 10 行或者 1000 字符会折叠成一个原子 token ,提交的时候才展开,不会把输入框撑爆。
界面本身有一个刻意的决定,llm 不是 TUI 。 codex 那类工具的交互是整屏接管的 alternate screen ,排版确实漂亮,代价是终端被它接管,输出不进 scrollback ,翻页搜索复制全是它自己实现的一套键位,退出之后屏幕恢复原样,刚才聊了什么全靠会话恢复找回来。
llm 是普通的行式会话,回答逐行追加在终端的正常缓冲区里,回看历史用终端自己的滚动,搜索复制是终端原生功能,和你在 shell 里的肌肉记忆完全一致。渲染也是 write-once 的,回答区每个字节只写一次,不擦除不重绘,所以 kitty 、tmux 、纯 ssh 甚至串口控制台下面行为都一样。
这类界面正经的名字是 REPL ,read-eval-print loop ,读一句执行一句打印一句再等下一句,psql 、sqlite3 、python 的交互模式都是这个形态,llm 的会话就是一个 chat REPL 。附件也可以从命令行给 llm -a shot.png "what is wrong here?
llm -a /ai-market-guide/ "summarise this" llm -a - "what is this? /dev/null 这种没事。
第二层是黑名单文件,值得展开讲讲。 ~/.llm/blacklist 对所有项目生效, .llm/blacklist 放在仓库根目录只对这个仓库生效,同名规则项目的行优先。
这一层只能在硬拒绝之上做加法,你想放行某个硬拒绝是做不到的,硬拒绝就是硬拒绝。文件首次生成时自带两条规则加一堆语法注释 rm git push --force* 删掉文件下次启动就重置回这两条。
每行一个规则,写法有四种 deploy # 命令词,拦 deploy x 、deploy --all 、echo hi | deploy git push --force origin main # 完整命令段,全部对上才拦 rm -* # glob ,拦 rm -rf 、rm -r ,但拦不住裸 rm ! git push --force* # 反向放行,把前面对上的规则又放回来 匹配是对 shell 解析后的命令逐段做的,所以管道后面的段也能命中, echo hi | deploy 里 deploy 会被单独看到。
!开头的行用来在黑名单上开口子,比如全局拦了 rm -* ,项目里写 !
rm -rf node_modules 就把这一种放回来了。普通的 rm 默认不拦,删文件本来就是日常开发的一部分,真正危险的是 rm -rf 指错了地方,默认规则拦得比你想象中宽松,要严可以自己加 rm -* 。
黑名单命中时会弹提示,高亮匹配到的那一行规则,可以看到到底是哪条规则拦住了你,按 a 在本次会话内放行这个模式,后续同模式的调用不再问。要说诚实的话,这个检查是词法层面的,它看得到的是解析出来的命令段,看不到运行时才拼出来的东西,比如 cmd="rm"; $cmd -rf / 这种就不在它的视野里,扩展工具自己 spawn 的进程也不经过这条路。
所以它是提示不是紧闭的牢笼,真要防护还是配合用户权限,拿日常账号跑 agent ,别拿 root 跑。再细的控制写在 config.json 里,每个工具都可以单独给策略 { "agent": { "tools": {"bash": "prompt", "git_push": "deny"} } } allow 、 deny 、 prompt 三个值,双向都行,该问的问,该禁的禁,这一层的优先级最高。
扩展系统 这是我最喜欢的设计。任何脚本开头加几行注释,它就变成了一个工具,host 每次调用的时候把它 spawn 起来,喂参数,收 stdout 作为结果 #!
/usr/bin/env python3 # --- llm-tool: wordcount # description: count characters, words and lines of a text # args: text (string) the text to measure # arg-mode: argv # interpreter: python3 # timeout: 10 import sys text = sys.argv[1] if len(sys.argv) > 1 else "" print(f"{len(text)} chars · {len(text.split())} words") 不需要可执行权限,声明了 interpreter 就用指定的解释器跑,这一招在 Windows 上同样好使。 Python 、shell 、R ,手边有什么就用什么写。
只有一个参数的时候加一行 arg-mode: argv ,参数就以普通命令行参数的形式进来,不用解析 JSON ,shell 脚本写起来毫无负担。文件放进 ~/.llm/extensions/ 是全局生效,放进项目的 .llm/extensions/ 只对这个项目生效,两边同名的时候项目那份赢,改完 /reload 一下就挂上了。
更重的需求用常驻扩展。没有 manifest 头的文件按会话启动一次,通过 stdio 一行一个 JSON 和 host 通信。
启动时 host 先发 initialize ,扩展声明自己提供哪些工具、哪些斜杠命令、关心哪些事件,之后模型调用工具、用户敲命令、回合边界的时候 host 都会回调。整个协议可以用一小段 Python 讲完 #!
/usr/bin/env python3 import json, sys def reply(obj): sys.stdout.write(json.dumps(obj) + "\n") sys.stdout.flush() for line in sys.stdin: req = json.loads(line) if req.get("type") == "initialize": reply({"id": req["id"], "result": {"tools": [ {"name": "deploy", "description": "Deploy the current tree", "parameters": {"type": "object", "properties": {}}}], "commands": [], "events": []}}) elif req.get("type") == "call_tool": import subprocess out = subprocess.run(["deploy.sh"], capture_output=True, text=True) reply({"id": req["id"], "result": out.stdout or out.stderr}) elif req.get("type") == "shutdown": break 这就是一个完整的部署工具,核心一行都没改。
扩展往 stderr 打的任何东西都是给人看的通道,调用进行中时会一行一行流进这次调用的工具日志,相当于长任务的实时进度,但是不会混进给模型的结果里。工具默认 120 秒超时,跑构建或者跑另一个 agent 这种长活在 initialize 里自己报一个更长的期限,上限一小时。
扩展崩了会在下次用到的时候懒重启,代价是丢一次调用,不是丢整个会话。事件里最有用的是 tool_call ,它在每次工具真正执行之前触发,扩展可以拒绝这次调用,可以改写它的参数,也可以替它跳过审批提示。
比如不想让 agent 在某个挂载了网络盘的目录里乱删东西,写一个几行的监听就拦住了,项目级的 guardrail 全靠这条缝,比任何模式开关都精准。 tool_result 事件更进一步,能拿到工具的完整输出并改写模型实际读到的内容,日志去重、敏感信息脱敏都插在这里,线程文件里仍然保留原始输出,改坏了也赖不掉。
MCP MCP 没有做进内核,而是作为一个常驻扩展存在。 examples/extensions/ mcp_bridge.py 会读取放在自己旁边的 mcp.json ,把里面每个 server 的工具挂载成 server__tool 形式的工具,审批矩阵照常适用。
stdio 和 streamable HTTP 两种传输都支持,token 用 ${VAR} 从环境变量展开,不用明文写进文件 { "cloudflare": { "type": "streamable-http", "url": "/ai-market-guide/ "headers": {"Authorization": "Bearer ${CLOUDFLARE_MCP_TOKEN}"} }, "fetch": {"command": "uvx", "args": ["mcp-server-fetch"]} } 把文件拷进扩展目录,改一下旁边的 mcp.json , /reload 完事。一个 MCP server 握手失败就跳过它打个暗色警告,其余的照常挂载,不会因为一个坏 server 拖死整个会话。
subagent subagent 同样是扩展而不是内核特性。 examples/extensions/ subagent.py 挂载一个 subagent 工具,spawn 一个子 llm 进程,让它在自己的上下文窗口里干活,只把结论作为工具结果交回来。
agent 的定义就是带 frontmatter 的 markdown 文件,放在 ~/.llm/agents/ 或者项目的 .llm/agents/ 下面 --- name: scout description: read-only scout that locates code and files tools: read, grep, glob, ls --- You are a scout. Locate code and report paths with brief evidence. Do not edit anything. tools 、 model 、 thinking 都是可选的,正文就是附加的系统提示词。仓库里附了四个可以直接抄的定义, scout 和 reviewer 只读, planner 只思考不动手, worker 可以改文件可以跑命令。
子进程通过 --json 输出事件流,扩展把它转成父会话里的实时工具日志,答案从最后的 result 对象里拿,完全不用解析面向人类的文本。 ctrl+c 会沿着链路传下去把子进程也停掉,深度守卫拒绝套娃,子 agent 的工具白名单也不包含 subagent 自己。
核心对 subagent 的存在一无所知,这就是扩展系统该有的样子。 skills 和提示词模板 skills 放在 ~/.llm/skills/ 下面,一个目录一个 SKILL.md , description 是模型匹配任务的依据。
agent 会在 /help 里列出所有可用的 skill ,可以 /skill:name 手动跑,模型看任务像的时候也会自己挑。运行的时候 skill 拿到自己所在目录作为工作目录,里面引用的 references/ 、 scripts/ 和各种资源在哪儿启动都能解析对。
之前写过的生信项目梳理提示词,现在可以直接做成一个 skill ,每个项目目录里一敲就跑。提示词模板解决另一个问题,就是反复敲同样的话。
把一个 .md 文件放进 ~/.llm/commands/ , /name 就变成了一个斜杠命令,正文就是提示词,frontmatter 里可以加一个额外的 system 提示词, $input 接住命令名后面的所有参数,两边都会做替换。比如写一个 review.md ,之后 /review src/ main.rs 就是拿模板跑这个文件。
再往上一层是包管理。 llm install git: github.com/user/repo 把一个 git 仓库克隆到 ~/.llm/pkg/ 下面,里面的 extensions/ 、 skills/ 、 commands/ 会挂进正常的发现路径, -l 装到项目本地, -g 是默认的全局。
仓库根上一个 SKILL.md 也算数,整个仓库变成一个 skill ,现在大多数独立 skill 仓库就是这个形状,直接装就能用。 llm list 看每个包带了什么, llm remove 删掉。
没有 npm 这条路,纯 git ,装之前自己审一遍,毕竟扩展是拿你的完整权限跑的。会话管理 每个会话都存成 ~/.llm/threads/ 下面的一个 JSONL 文件,模型、参数、每一轮的 token 用量、工具调用和结果全程在案,随时回来接着聊 llm -c "and in python?
接着最新的会话问,先看当前目录再全局找 llm -r # 浏览并恢复历史会话,可以边打字边过滤 llm --session 01ABC... "..." # 指定一个,短前缀就行 llm --fork "..." # 从当前会话分叉一条新的 llm --no-session "..." # 这次不存 llm export notes.md # 把会话导出成 markdown 会话 id 是 ULID ,按时间有序。导出的 markdown 里工具调用和结果都在代码块里,思考过程收在专门的段落,系统提示词作为附录,attachments 按名字和类型记录而不是塞字节,直接能当文档归档。
上下文和网络 上下文管理基本不用管,但管得很细。计费上下文超过窗口减 16384 的时候触发 compaction ,保留最近 20k token ,把丢掉的前缀总结成摘要,窗口大小未知的模型按 64k 走。
如果请求发出去被 provider 告知超长,会立刻压缩再重试,不会让会话一次一次撞同一堵墙。 cache_ttl 可以选 5m 或者 1h ,长的那档写入计费贵一点,但是一次审批提示或者一轮长测试超过五分钟以后,下一轮就不用重写整个对话了,算下来是划算的。
网络层的细节都是踩坑换来的。重试带正负 10% 的抖动,退避从 1 秒到 30 秒,连接失败另有一套 5 秒到 60 秒的预算,服务器发了 Retry-After 就尊重它。
流中途断了的话,已经产出的部分答案会保留成一条真正的消息接着续,而不是整轮丢掉重来,恢复次数上限五次。完全没有输出就断掉的请求原样重发,因为什么都没交出去,重发不会重复。
300 秒没有任何输出的流按空闲错误处理,不让任务干挂在那里。请求体超过 32MB 会被本地拒绝,并且点名是哪些附件把它撑满的, agent.max_request_bytes 可以调低,网关在前面的时候经常比 provider 文档写的上限小得多。
代理走 ALL_PROXY 、 HTTPS_PROXY 、 HTTP_PROXY 和 NO_PROXY ,自动生效,国内环境很实用。项目结构 代码组织按角色分层,一句话说清楚就是命令层薄,内核层厚,协议适配一层一个文件 llm/ ├── src/ │ ├── commands/ # 子命令,一个文件一个,flags 、help 、接线 │ ├── core/ # 共享内核,配置、线程存储、http 、渲染 │ ├── providers/ # 协议适配器,openai-compat 和 anthropic 各一个,加共享消息模型和 provider 目录 │ ├── agent/ # agent 领域,循环、工具注册表、审批规则、扩展宿主 │ └── term/ # 终端领域,行编辑、选择器、spinner 、终端尺寸 ├── examples/ │ ├── extensions/ # 可运行的扩展,wordcount 、websearch 、mcp_bridge.py 、subagent.py 等 │ └── agents/ # 四个 subagent 定义,scout 、reviewer 、planner 、worker └── docs/ ├── architecture.md # 请求路径、agent 循环、工具注册表、扩展宿主、渲染契约 └── extensions.md # 插件接口,manifest 字段、每个消息和事件、tool_call 门、超时 这种分法的好处是改哪里都能立刻找到地方。
加一个子命令就是往 commands/ 里放一个新文件,接一个新协议就是往 providers/ 里放一个适配器,agent 循环本身在 agent/ 里,和终端呈现完全解耦, --json 模式下整个 term/ 都不参与。测试全部内联在各模块的 #[cfg(test)] 里,就近看行为,单个跑用 cargo test 。
扩展宿主也遵循同样的克制。 host 负责 spawn 、stdio 、超时和尺寸上限,扩展自己声明工具、命令和事件,核心不认识任何一个具体扩展,MCP 桥和 subagent 都住在 examples/ 里,和用户自己写的扩展没有地位差别,删掉它们核心照常编译。
想快速冒烟一下不改自己的状态 LLM_USER_PATH=/tmp/x cargo run -- "smoke test prompt" 和 pi 的对比 写的过程中主要参照的对象是 pi