教程仅用Bash、curl和jq,从单次模型调用逐步构建聊天机器人与编程代理,并演示生成俄罗斯方块游戏。配套源码已公开,可连接支持工具调用的本地或远程模型。
这篇文章将带你一步步构建一个极简 AI harness:从单次 LLM 调用,到聊天机器人,再到能开发俄罗斯方块这类游戏的编程 Agent。整个过程只用 Bash 脚本。
全部源代码都在 GitHub 的 antonum/bash-harness 仓库中。
你需要准备:
首先,我们要能从 bash 调用 LLM。参见 01-LLM-Call.sh。
OPENROUTER_API_KEY="sk-or-v1-12345....."
curl https://openrouter.ai/api/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-d '{
"model": "openai/gpt-6-luna",
"max_tokens": 1500,
"messages": [
{
"role": "user",
"content": "In one sentence, what is jq?"
}
]
}'
直接在终端运行这段代码,应该就能得到一大段 JSON。LLM 的回答就藏在其中。
用 jq 解析输出后,读起来会轻松一些,只需通过管道把结果传给 jq .。
bash 01-LLM-Call.sh | jq .
{
"id": "gen-1792430673-n7TeT6PuaQl4j2fmQhpj",
"object": "chat.completion",
"created": 1792430673,
"model": "openai/gpt-6-luna",
"provider": "OpenAI",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "jq is a lightweight command-line tool for querying, transforming, and formatting JSON data.",
"refusal": null,
"reasoning": null,
"reasoning_details": [ ... ]
}
}
],
"usage": {
"prompt_tokens": 14,
"completion_tokens": 21,
"total_tokens": 35,
...
}
}
如果只想从这段 JSON 中提取回答文本,也可以用 jq:
bash 01-LLM-Call.sh | jq '.choices[0].message.content'
"jq is a lightweight command-line tool for querying, transforming, and formatting JSON data."
到这里一切顺利。我们可以在 bash 脚本中用 curl 调用 LLM,LLM 返回 JSON,再用 jq 解析结果。模型刚刚为我们解释了 jq 是什么,接下来我们会大量使用它。
这里有一点很重要:每次 LLM 调用都完全“无状态”。LLM 不知道你之前问过什么,也不知道自己昨天或几秒前回答过什么。它始终只根据当前请求中的上下文和自身知识作答,除此之外没有别的信息。补充合适的上下文,也就是聊天记录,以及给 LLM 提供可执行的工具,比如读写文件,都是 harness 的职责。
接下来的两步,我们就来构建这个 harness。开始吧!
要把单次调用变成聊天,只需要做两件事:记住所说过的话,以及循环调用。参见 02-Chat.sh。
对话其实就是保存在 shell 变量中的一个 JSON 消息数组。我们不手写 JSON,而是让 jq 来构建:
MESSAGES='[]'
add_message() {
MESSAGES=$(jq --arg role "$1" --arg content "$2" \
'. + [{role: $role, content: $content}]' <<<"$MESSAGES")
}
把第 1 步中的 curl 调用放进一个函数,接收整个数组,并将它作为 messages 发送:
call_llm() {
local body
body=$(jq -n --arg model "$MODEL" --argjson messages "$1" \
'{model: $model, max_tokens: 1500, messages: $messages}')
curl -s https://openrouter.ai/api/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-d "$body"
}
然后是循环:读取一行输入,以 user 身份追加到消息中,调用 LLM,以 assistant 身份追加回复,打印出来,再重复这个过程。
while true; do
IFS= read -e -r -p "you> " input || break
[ -z "$input" ] && break
add_message user "$input"
response=$(call_llm "$MESSAGES")
reply=$(jq -r '.choices[0].message.content' <<<"$response")
add_message assistant "$reply"
printf 'llm> %s\n\n' "$reply"
done
$ bash 02-Chat.sh
Chat with openai/gpt-6-luna. Empty line or Ctrl-D to quit.
you> My name is Anton
llm> Nice to meet you, Anton! How can I help you today?
you> What is my name?
llm> Your name is Anton.
第二次调用前,MESSAGES 的内容如下。这就是模型看到的全部请求内容,没有其他东西:
[
{ "role": "user", "content": "My name is Anton" },
{ "role": "assistant", "content": "Nice to meet you, Anton! How can I help you today?" },
{ "role": "user", "content": "What is my name?" }
]
注意,LLM 之所以“记得”这个名字,只是因为我们在第二次调用时又发送了一遍完整对话。服务端没有记忆。harness 就是记忆。每一轮,数组都会变长;每一轮,我们都会把整个数组发送过去。
这就是一个用 40 行 bash 写成的聊天机器人。现在,让它干点实事。
工具没什么神奇的。我们告诉 LLM:“这里有一个函数,你可以要求我调用它。”随后,LLM 不再用文本回答,而是回复:“请用这些参数运行这个函数。”我们执行函数,把结果发回去,LLM 再继续处理。参见 03-Harness.sh。
我们只给它一个工具,也是威力最大的那个:bash。
TOOLS='[{
"type": "function",
"function": {
"name": "bash",
"description": "Run a bash command on the user'"'"'s machine and return its stdout, stderr and exit code.",
"parameters": {
"type": "object",
"properties": { "command": { "type": "string", "description": "The command to run." } },
"required": ["command"]
}
}
}]'
这段 JSON 会作为 tools 字段,与 messages 一起放进每个请求。call_llm 增加一个字段,并改成一条管道:jq 构建请求,curl 通过 -d @- 从 stdin 读取。每次调用都会重新发送完整对话,而对话只会越来越长;macOS 上命令行大小的上限是 1 MB,因此请求体始终不作为命令行参数传递。
call_llm() {
jq -n --arg model "$MODEL" --argjson messages "$1" --argjson tools "$TOOLS" \
'{model: $model, max_tokens: 8000, messages: $messages, tools: $tools}' |
curl -sS https://openrouter.ai/api/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-d @-
}
现在,我们还需要追加三种不同结构的消息:用户文本、assistant 回复,以及工具执行结果。我们没有为此编写三个函数,而是让 add_message 接收 jq 参数和一个对象表达式,这样每个调用位置都能明确写出要追加的 JSON:
add_message() {
MESSAGES=$(jq --argjson m "$(jq -nc "$@")" '. + [$m]' <<<"$MESSAGES")
}
add_message --arg c "$input" '{role: "user", content: $c}'
add_message --argjson m "$message" '$m'
add_message --arg id "$call_id" --rawfile c <(printf '%s' "$result") \
'{role: "tool", tool_call_id: $id, content: $c}'
最后一次调用使用了 --rawfile 和进程替换,原因与 curl 使用 -d @- 一样:工具输出大文件时,不能让命令行超出大小限制。
另一个新增部分,就是执行命令,并捕获模型了解执行情况所需的全部信息:
run_tool() {
local output status=0
output=$(bash -c "$1" 2>&1) || status=$?
printf '%s\n[exit code: %d]' "$output" "$status"
}
接下来是循环。之前的流程是:用户说点什么,LLM 回复,然后打印。有了工具后,还需要第二层内部循环:LLM 回复,如果回复中包含 tool_calls,就执行这些调用,把结果作为 tool 消息追加进去,再次调用 LLM。只有当回复中不再包含工具调用时,我们才打印回复,把控制权交还给用户。
add_message --arg c "$input" '{role: "user", content: $c}'
while true; do
response=$(call_llm "$MESSAGES")
message=$(jq -ce '.choices[0].message' <<<"$response") ||
{ printf 'ERROR: %s\n\n' "$(jq -r '.error.message // .' <<<"$response")"; break; }
add_message --argjson m "$message" '$m' # echo it back verbatim, tool_calls and all
if ! jq -e '.tool_calls | length > 0' <<<"$message" >/dev/null; then
printf 'llm> %s\n\n' "$(jq -r '.content' <<<"$message")"
break
fi
while IFS= read -r call; do
call_id=$(jq -r '.id' <<<"$call")
command=$(jq -r '.function.arguments | fromjson | .command' <<<"$call")
printf 'bash> %s\n' "$command"
result=$(run_tool "$command")
add_message --arg id "$call_id" --rawfile c <(printf '%s' "$result") \
'{role: "tool", tool_call_id: $id, content: $c}'
done < <(jq -c '.tool_calls[]' <<<"$message")
done
有两个细节需要了解。包含 tool_calls 的 assistant 消息必须按收到时的原样放回历史记录,因此我们追加原始 JSON,而不是重新构建它。另外,每个工具结果都带有它所回应的 tool_call_id,方便 API 将两者对应起来。
脚本还有两个上面没有展示的小功能。curl 等待模型响应时,一个后台子 shell 会每秒在同一行刷新一次 thinking Ns,响应到达后用 kill $! 停止它。另外,如果回复因 max_tokens 限制而被截断,参数字符串可能不完整,导致无法解析;脚本会捕获这种情况,并向模型返回错误,而不是直接崩溃。
这个内部循环就是整个“Agent”。其余的一切,包括 Claude Code、Codex、Cursor 等,都是在这个循环之上做了大量完善。
$ bash 03-Harness.sh
Agent with bash tool (openai/gpt-6-luna). Empty line or Ctrl-D to quit.
you> what's the weather in Toronto?
bash> curl -s 'wttr.in/Toronto?format=3'
Toronto: ⛅️ +12°C
[exit code: 0]
llm> It's currently partly cloudy and about 12°C in Toronto.
向第 2 步的聊天机器人提出同样的问题,它会告诉你,它无法访问实时数据。同一个模型,同一个问题。区别就在请求中的 tools 字段。模型看到有 bash 工具,推断出有 shell 就能访问网络,于是自行选择 curl 和天气服务,读取输出,再组织成回答。我们从未在脚本的任何地方提到 curl、wttr.in 或天气。这就是工具的意义:模型负责规划,harness 只负责执行模型提出的要求。
下面是这轮交互后的 MESSAGES 数组。五条消息,两次 LLM 调用。第一次调用返回了包含 tool_calls 的消息,第二次调用返回了最终文本:
[
{
"role": "system",
"content": "You are a helpful assistant running on the user's machine. Use the bash tool ..."
},
{
"role": "user",
"content": "what's the weather in Toronto?"
},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_x7Kq2",
"type": "function",
"function": {
"name": "bash",
"arguments": "{\"command\": \"curl -s 'wttr.in/Toronto?format=3'\"}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_x7Kq2",
"content": "Toronto: ⛅️ +12°C\n[exit code: 0]"
},
{
"role": "assistant",
"content": "It's currently partly cloudy and about 12°C in Toronto."
}
]
这里有几处值得留意。arguments 字段是嵌在 JSON 中的 JSON 字符串,所以脚本要用 fromjson 解析它。tool 消息回应的是某个特定的 tool_call_id。工具输出只是文本,模型会像你一样理解 [exit code: 0]。在任意 add_message 调用后面加上 jq . <<<"$MESSAGES" >&2,就能实时观察这个数组如何增长。
下面是之前承诺的压轴演示:
you> write a tetris game in python, make sure it runs
bash> python3 --version
Python 3.13.2
[exit code: 0]
bash> cat > tetris.py <<'EOF'
#!/usr/bin/env python3
"""A small, dependency-free Tetris game. Run with: python3 tetris.py"""
...
bash> python3 -c "import ast; ast.parse(open('tetris.py').read())"
[exit code: 0]
bash> python3 -c "import tkinter"
ModuleNotFoundError: No module named '_tkinter'
[exit code: 1]
bash> cat > tetris.py <<'EOF'
...
llm> Done. tetris.py is written. Tkinter wasn't available on this machine, so the game falls
back to a curses version that runs in the terminal. Run it with: python3 tetris.py
看看刚才发生了什么。模型检查了 Python 版本,写入文件,验证代码能否解析,发现缺少 Tkinter,然后重写游戏,加入终端运行的备用方案。没有人告诉它要做这些。我们给了它一个工具和一个循环,规划则来自模型。
生成的 tetris.py 就在仓库里。它能运行。
提醒一句:这个脚本会使用你的权限执行模型要求的任何操作,不会询问。这是一个教程。请留意屏幕上滚动显示的命令,也不要在你在意的目录中运行它。
三个脚本,去掉注释和空行后,总共只有 62 行 bash:
真正的编程 Agent 在此之上增加的东西,比如更多工具、权限、上下文管理、更友好的输入方式,都是围绕这个核心展开的工程工作。核心本身,一屏就能装下。
Claude Code 和 Codex 就像现代汽车:安全气囊、电动车窗、空调,一应俱全。我们在这里造出的,则是发动机、变速箱、底盘和四个轮子。配置简陋到了极点,但它确实能开。下面这些功能,花一个下午就能加上:
read -e 已经使用了 readline,因此只需在每次读取后执行一次 history -s "$input",就能用上下方向键翻阅输入。如果还想采取进一步措施,可以考虑屏蔽此人和/或举报滥用行为。