☰
基于llama.cpp的极简Coding Agent实现:从零搭建本地编程助手
2026/10/15 20:48:31 网站建设 项目流程

最近在整理本地 AI 编程工具链的时候,我一直在想一个问题:做一个 coding agent,真的需要一整套重型框架吗?

如果你也试过本地部署 coding agent,应该能感受到一个很直接的矛盾:模型越来越强,外围框架却越来越重。为了让模型能读文件、执行命令、自己写代码,很多人第一反应是接入一个完整的 Agent 框架。但框架带来的抽象层、事件总线、插件机制、图形化编排,往往让你的排查成本比写业务代码还高。

这篇文章我想分享一种更朴素的方案:直接在 llama.cpp 上构建一个极简 coding agent。项目代号就叫 DLLM,核心主张是 minimal、clean、without overhead——把 llama.cpp 的llama-server当作模型推理后端,它对外暴露 OpenAI 兼容接口;然后我们自己用不到两百行 Python 实现 Agent 循环里的关键能力:规划、工具调用、执行。全程不引入额外的 Agent 中间件。

这篇文章适合两类读者:一类是想搞懂 coding agent 底层原理的开发者,另一类是希望在公司内网或本地环境跑一个轻量编程助手的后端工程师。读完你可以掌握 llama-server 的启动和接口用法、GGUF 模型的选择思路、工具调用(function calling)的原理,以及一套可以直接复制运行的极简 coding agent 代码。

1. 背景与核心概念

1.1 Coding Agent 是什么,为什么需要它

Coding agent 指的是能自主完成编程任务的智能体。给它一个需求,比如“写一个 Python 脚本,读取 CSV 并生成统计报表”,它能自己规划步骤、查看项目文件、编写代码、执行命令、根据结果修正,直到任务完成。

和普通的“聊天补全”相比,coding agent 最大的区别在于它会采取行动。它不只是生成一段代码文本,而是通过工具调用(tool calling)真正操作文件系统、执行命令、运行测试。这个能力让它可以完成多步骤、需要验证的工程任务。

最近一两年,coding agent 的形态变化很快。从最初的“单轮对话生成代码”,到“多轮工具调用”,再到“规划 + 执行”分离的两阶段模式,演化路线非常清晰。业界不少 Agent 产品已经把流程拆成 Agent Plan(任务规划)和 Coding Plan(编码执行)两个阶段,先让模型输出一份可执行的计划,再根据计划逐步编码。这种做法的好处是能明显减少模型在长任务中途“迷路”的问题。

1.2 为什么要在 llama.cpp 上直接构建

llama.cpp 是一个以 C/C++ 实现的大模型推理引擎,主打本地运行、资源占用可控、支持 CPU/GPU 混合推理。它本身不提供“智能体”能力,但它把一个非常重要的部分做好了:把 GGUF 模型加载、量化推理、聊天模板、工具调用协议解析,统一封装成一个 HTTP 服务。

llama-server是 llama.cpp 提供的 HTTP 服务程序,它对外提供 OpenAI 兼容的/v1/chat/completions接口。这意味着你可以用标准的 OpenAI SDK 或者普通 HTTP 请求去调用本地模型,不需要关心底层推理细节。

直接在 llama.cpp 上构建 agent 的好处也很明显:

  • 依赖少:不需要装一套几百 MB 的 Agent 框架。
  • 数据不出内网:适合代码仓库敏感的场景。
  • 成本可控:本地跑模型没有按 token 计费,批量实验不心疼。
  • 原理透明:Agent 循环自己写,每一步发生了什么完全可观测。

DLLM 这个名字,我的理解是 Direct + LLM + Minimal 的结合:直接使用 LLM 推理运行时,保持最小实现。本文就把这个项目当作一个手工实现的示例来拆解。

1.3 一个容易混淆的点:llama.cpp 不等于 Agent 框架

很多初学者会把“用了 llama.cpp”和“有了 Agent”混为一谈。实际上:

  • llama.cpp / llama-server 只负责模型推理和协议的编解码。
  • Agent 循环(决定下一步调用什么工具、何时结束)必须由外部程序实现。

DLLM 的价值就在于把后者做得很薄。你可以把它理解成一个“胶水层”:左边是模型推理服务,右边是你的文件系统、命令行、代码仓库,中间只靠 OpenAI 兼容协议通信。

2. 环境准备与版本说明

在开始写代码之前,先把环境准备好。本文的示例环境如下,具体版本请以你本机的实际情况为准:

项目说明
操作系统Linux / macOS / Windows(WSL2)均可,本文命令以 Linux 风格为例
推理后端llama.cpp 的llama-server,建议使用较新的 release 版本
模型格式GGUF,例如 Qwen3 系列的量化模型
Python3.10 及以上
Python 依赖openai、requests
硬件建议至少 16GB 内存;有 NVIDIA GPU 更好

不同版本的 llama.cpp 参数会有些差异,以下命令基于较新的 llama.cpp 版本。如果你的版本参数不同,用llama-server --help查看即可。

2.1 安装 llama.cpp

llama.cpp 的安装方式有两种常用路径:直接下载官方 release 的可执行文件,或者从源码编译。

源码编译适合需要针对本机 CPU 指令集优化、或者需要特定 CUDA 版本的场景:

git clone https://github.com/ggerganov/llama.cpp cd llama.cpp cmake -B build -DGGML_CUDA=ON cmake --build build --config Release -j

如果你的显卡不支持 CUDA,或者只想用 CPU 跑,可以去掉-DGGML_CUDA=ON:

cmake -B build cmake --build build --config Release -j

编译完成后,llama-server可执行文件位于build/bin/目录下。验证一下:

./build/bin/llama-server --version

如果输出版本信息,说明编译成功。为了方便后续调用,可以把build/bin加入PATH,或者直接在命令里写绝对路径。

2.2 下载 GGUF 模型

llama.cpp 使用 GGUF 格式的模型文件。你可以从 Hugging Face、魔搭社区等模型仓库下载已经转换好的 GGUF 模型。

选择模型时主要看两个维度:模型本身的编程能力和你的硬件资源。

  • 如果你的显存或内存比较紧张(8GB 左右),可以选择 7B-8B 级别的模型,量化等级建议 Q4_K_M,文件大小大约 4-6GB。
  • 如果内存或显存比较充足(24GB 左右),可以选择 27B-32B 级别的模型,编程能力会明显更强,但推理速度也会变慢。
  • 纯粹的 CPU 推理也可以跑,但建议选择 8B 以下的模型,并接受较慢的生成速度。

本文实战部分以 Qwen3 系列 GGUF 模型为例。模型文件下载后放到一个固定目录,例如:

models/ └── qwen3-8b-q4_k_m.gguf

注意:模型文件名可以自定义,llama-server默认会把模型文件名作为 model id,也可以用--alias手动指定。

2.3 准备 Python 环境

我们只需要很薄的客户端依赖。创建项目目录并安装:

mkdir dllm cd dllm python -m venv .venv source .venv/bin/activate pip install openai requests

到这里,整个环境就绪了。目录结构建议如下:

dllm/ ├── agent.py # Agent 主循环 ├── client.py # OpenAI 兼容客户端封装 ├── tools.py # 工具函数定义与执行 ├── requirements.txt # 依赖清单 └── demo_project/ # Agent 的工作目录(可自动创建)

3. 核心原理:GGUF、llama-server 与 Agent 循环

在写代码之前,有必要把几个核心概念讲清楚。理解了这些,后面遇到问题才知道从哪里排查。

3.1 GGUF 与量化

GGUF 是 llama.cpp 使用的模型格式,它把模型权重、分词器、聊天模板等打包在一个文件里。量化(quantization)则是把模型权重从 16 位浮点数压缩成更低精度,比如 4 位整数,从而大幅减小文件体积和内存占用。

常见的量化等级包括 Q4_K_M、Q5_K_M、Q8_0 等。其中 Q4_K_M 是性价比比较高的选择,文件小、推理速度快,质量损失对编程任务来说通常可以接受。

这里有一个容易踩的坑:量化后的 GGUF 模型文件是可以脱离原始模型直接运行的,但必须由 llama.cpp 运行时来加载。如果你在一个工具里看到类似 “this is a GGUF model, but no executable llama.cpp runtime (llama-server) is found” 的报错,意思是工具找到了模型文件,但没找到负责运行它的llama-server可执行程序。这个问题后面会专门讲。

3.2 llama-server 的 OpenAI 兼容接口

启动llama-server后,它会监听一个 HTTP 端口,默认是 8080。常用接口包括:

  • GET /v1/models:查看当前加载的模型列表。
  • POST /v1/chat/completions:聊天补全接口,支持 tools 参数。
  • POST /v1/completions:原生文本补全接口。
  • POST /v1/embeddings:向量化接口,常用于本地 RAG。

我们主要用/v1/chat/completions。它的请求格式和 OpenAI 官方接口几乎一致,因此可以直接用 openai 的 Python 客户端连到http://127.0.0.1:8080/v1。

要注意的一点是:llama-server只负责解析请求、调用模型、按聊天模板生成回复。它并不知道“工具”是什么意思,它只是把模型输出的tool_calls按照协议返回给你。真正执行工具逻辑的程序必须自己写。

3.3 工具调用(Function Calling)的原理

工具调用的流程可以拆成三步:

  1. 客户端把可用的工具列表(函数名、参数 schema)随消息一起发给模型。
  2. 模型判断当前需要调用某个工具时,不会直接回答用户,而是返回一个tool_calls结构,里面包含函数名和 JSON 格式的参数。
  3. 客户端执行这个函数,把结果作为role: "tool"的消息继续发给模型,模型再基于结果继续推理。

这个循环会一直持续,直到模型认为任务完成、不再返回tool_calls。

需要特别强调的是:并非所有模型都支持高质量的工具调用。工具调用能力取决于模型的训练数据。Qwen3 系列、Llama 3.1+ 系列等模型对 function calling 支持较好。如果模型本身不会用工具,那无论客户端怎么写,它都不会返回tool_calls。

在 llama.cpp 侧,启动llama-server时建议加上--jinja参数。这个参数会启用模型自带聊天模板中的 Jinja 模板解析,对 Qwen3 这类模型的工具调用支持是必需的。

3.4 Agent 循环与 Plan/Coding Plan 分离

一个最朴素的 Agent 循环如下:

用户任务 -> 发送消息和工具列表给模型 -> 模型返回 tool_calls 或最终答案 -> 如果是 tool_calls,执行对应工具,把结果追加到消息里,回到上一步 -> 如果没有 tool_calls,输出最终答案,结束

这个循环实现起来很简单,但实战中直接这样跑会有一个问题:任务越复杂,模型在中途越容易“忘记”最初的目标,或者突然路径跑偏。于是出现了两阶段的做法:

  • Agent Plan(任务规划阶段):模型先不参考工具,只分析需求、拆解步骤,输出一份计划。
  • Coding Plan(编码执行阶段):把这份计划连同用户任务一起发给模型,再让它进入工具调用循环,逐步执行。

这种“先计划、后编码”的拆分在业界产品中已经很常见,比如火山引擎的 Agent Plan 与 Coding Plan 就是类似的思路。本文的实战部分会先实现基础的单循环,再演示如何加入规划阶段。

4. 实战:从零构建一个最小可用的 coding agent

下面进入实战环节。我们会分四步完成:

  1. 启动llama-server加载 GGUF 模型。
  2. 用 curl 验证接口是否可用。
  3. 编写客户端封装和三个核心工具。
  4. 实现 Agent 主循环和规划阶段。

4.1 启动 llama-server

假设模型文件位于models/qwen3-8b-q4_k_m.gguf,启动命令如下:

./build/bin/llama-server \ -m ./models/qwen3-8b-q4_k_m.gguf \ --alias qwen3 \ --host 127.0.0.1 \ --port 8080 \ --ctx-size 32768 \ --parallel 1 \ --jinja

参数说明:

  • -m:指定 GGUF 模型文件路径。
  • --alias:指定对外暴露的模型 id,客户端请求时model字段用这个名字。
  • --host 127.0.0.1:只监听本机,避免局域网内其他机器访问。
  • --port 8080:HTTP 服务端口。
  • --ctx-size:上下文窗口大小。编程任务经常涉及多轮工具调用,建议给大一些,但会占用更多内存。
  • --parallel:并行请求数。我们只跑单任务,设置为 1。
  • --jinja:启用 Jinja 聊天模板,模型工具调用依赖这个参数。

如果你的显卡显存充足,可以加上-ngl 999把尽可能多的层放到 GPU 上:

./build/bin/llama-server \ -m ./models/qwen3-8b-q4_k_m.gguf \ --alias qwen3 \ --host 127.0.0.1 \ --port 8080 \ --ctx-size 32768 \ --parallel 1 \ --jinja \ -ngl 999

启动成功后,终端会显示类似server is listening on http://127.0.0.1:8080的日志。

4.2 用 curl 验证接口

先确认模型列表:

curl http://127.0.0.1:8080/v1/models

如果看到包含qwen3的 JSON 返回,说明服务正常。

再测试一次最简单的对话:

curl http://127.0.0.1:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "qwen3", "messages": [ {"role": "user", "content": "用 Python 写一个快速排序函数"} ], "temperature": 0.2 }'

返回结果的choices[0].message.content里就是模型生成的代码。

到这里,推理后端已经就绪。接下来写客户端。

4.3 编写 OpenAI 兼容客户端

先创建client.py。它负责两件事:连接本地llama-server,发起带工具列表的聊天补全请求。

# client.py from openai import OpenAI BASE_URL = "http://127.0.0.1:8080/v1" MODEL = "qwen3" API_KEY = "sk-no-need" # llama-server 不校验 key,但 OpenAI SDK 需要非空 client = OpenAI(base_url=BASE_URL, api_key=API_KEY) def chat(messages, tools=None, tool_choice="auto", temperature=0.2, max_tokens=4096): """ 发送对话请求。 messages: List[dict],包含 system/user/assistant/tool 四种角色。 tools: 工具定义列表,参考 OpenAI function calling 格式。 """ params = { "model": MODEL, "messages": messages, "temperature": temperature, "max_tokens": max_tokens, } if tools: params["tools"] = tools params["tool_choice"] = tool_choice resp = client.chat.completions.create(**params) return resp.choices[0].message

这里有两个关键点需要解释:

  1. API_KEY随便填一个非空字符串即可。llama.cpp 的本地服务不校验收费鉴权,但 OpenAI SDK 会强制要求 api_key 参数存在。
  2. max_tokens建议设置得大一点。编程任务经常需要生成较长的代码,如果值太小,输出会被截断,导致 JSON 不完整或代码残缺。

4.4 定义三个核心工具

接下来创建tools.py。我们先实现一个 coding agent 最小可用的三个工具:

  • run_command:在工作目录执行 shell 命令。
  • read_file:读取项目文件。
  • write_file:写入或覆盖项目文件。
# tools.py import json import os import subprocess WORKDIR = "./demo_project" def tool_run_command(command: str) -> dict: """执行一条 shell 命令,返回退出码和输出。""" try: proc = subprocess.run( command, shell=True, cwd=WORKDIR, capture_output=True, text=True, timeout=60, ) return { "returncode": proc.returncode, "stdout": proc.stdout[-6000:], "stderr": proc.stderr[-2000:], } except Exception as exc: return {"error": str(exc)} def tool_read_file(path: str, line_start: int = 0, line_end: int = 0) -> dict: """读取文本文件,支持行号范围裁剪。""" full_path = os.path.join(WORKDIR, path) if not os.path.exists(full_path): return {"error": f"file not found: {path}"} try: with open(full_path, "r", encoding="utf-8") as f: lines = f.readlines() except UnicodeDecodeError: return {"error": "不是 UTF-8 文本,请改用 run_command 查看"} line_start = line_start or 1 line_end = line_end or len(lines) selected = lines[line_start - 1 : line_end] return {"path": path, "content": "".join(selected)[:6000]} def tool_write_file(path: str, content: str) -> dict: """写入文件,自动创建父目录。""" full_path = os.path.join(WORKDIR, path) os.makedirs(os.path.dirname(full_path) or ".", exist_ok=True) with open(full_path, "w", encoding="utf-8") as f: f.write(content) return {"ok": True, "path": path, "bytes": len(content.encode("utf-8"))} # 工具名 -> 执行函数 TOOL_EXECUTOR = { "run_command": tool_run_command, "read_file": tool_read_file, "write_file": tool_write_file, }

同时定义发给模型的工具 schema:

# tools.py 追加内容 TOOLS = [ { "type": "function", "function": { "name": "run_command", "description": "在项目目录中执行一条 shell 命令,用于查看文件、运行脚本、安装依赖等。", "parameters": { "type": "object", "properties": { "command": {"type": "string", "description": "要执行的 shell 命令"} }, "required": ["command"], }, }, }, { "type": "function", "function": { "name": "read_file", "description": "读取项目中的文本文件,可通过 line_start 和 line_end 指定行范围。", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "文件路径"}, "line_start": {"type": "integer", "description": "起始行号,从 1 开始"}, "line_end": {"type": "integer", "description": "结束行号"}, }, "required": ["path"], }, }, }, { "type": "function", "function": { "name": "write_file", "description": "写入或覆盖项目中的文本文件,content 为完整文件内容。", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "文件路径"}, "content": {"type": "string", "description": "完整文件内容"}, }, "required": ["path", "content"], }, }, }, ]

工具实现里有几个细节值得注意:

  • stdout和stderr都做了截断。如果不截断,一条超长日志很快就会把上下文窗口撑爆,模型会忘记前面做了什么。
  • read_file支持行号范围,模型可以只读取文件的一部分,避免一次性读入整个大文件。
  • 所有工具返回值都是字典,最后在 Agent 循环里统一json.dumps成字符串。这样模型拿到的工具结果格式是稳定的 JSON。

4.5 实现 Agent 主循环

现在写核心的agent.py。它负责组织消息、调用模型、执行工具、把结果回传,循环往复直到模型不再调用工具。

# agent.py import json import os from client import chat from tools import TOOLS, TOOL_EXECUTOR WORKDIR = "./demo_project" MAX_STEPS = 20 SYSTEM_PROMPT = ( "你是一个运行在 llama.cpp 之上的极简 coding agent。\n" "你的工作目录是 demo_project。工作流程:\n" "1. 先观察目录结构和已有文件;\n" "2. 拆解用户需求,形成执行步骤;\n" "3. 每一步只调用一个工具,不要一次性假设所有结果;\n" "4. 根据工具返回的真实结果决定下一步;\n" "5. 代码改动必须通过 write_file 写盘,并通过 run_command 运行验证;\n" "6. 任务完成后,用中文简短总结修改内容和验证结果,不再调用工具。\n" ) def run_agent(task: str, system_prompt: str = SYSTEM_PROMPT) -> list: messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": task}, ] for step in range(1, MAX_STEPS + 1): print(f"\n===== Step {step}: 请求模型 =====") msg = chat(messages, tools=TOOLS) assistant_msg = {"role": "assistant", "content": msg.content} if msg.tool_calls: assistant_msg["tool_calls"] = [ { "id": tc.id, "type": "function", "function": { "name": tc.function.name, "arguments": tc.function.arguments, }, } for tc in msg.tool_calls ] messages.append(assistant_msg) # 没有工具调用 -> 任务结束 if not msg.tool_calls: print("[Agent] 无工具调用,任务结束。") if msg.content: print(msg.content) return messages # 执行工具 for tc in msg.tool_calls: fn_name = tc.function.name try: fn_args = json.loads(tc.function.arguments) except json.JSONDecodeError: fn_args = {} print(f"[Step {step}] 调用工具 {fn_name} 参数={fn_args}") if fn_name not in TOOL_EXECUTOR: result = {"error": f"unknown tool: {fn_name}"} else: try: result = TOOL_EXECUTOR[fn_name](**fn_args) except TypeError as exc: result = {"error": f"参数错误: {exc}"} messages.append( { "role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False), } ) print( "[Step {}] 工具返回:{}".format( step, json.dumps(result, ensure_ascii=False)[:300] ) ) print(f"[Agent] 超过 {MAX_STEPS} 步仍未结束,强制终止。") return messages if __name__ == "__main__": os.makedirs(WORKDIR, exist_ok=True) user_task = input("请输入要交给 coding agent 的任务:").strip() if user_task: run_agent(user_task)

这段代码有几个关键点:

  1. assistant 消息必须保留tool_calls。后续请求里,模型需要看到自己之前调用了哪些工具,这是上下文一致性的一部分。
  2. 工具结果用role: "tool"回传,并带上tool_call_id,和 assistant 返回的tool_calls[].id对应。格式不对会导致模型无法关联工具调用和结果。
  3. 异常处理放在工具执行层。模型传入的参数不一定合法,比如read_file的line_start传了字符串,通过TypeError捕获并返回明确错误信息,模型看到后通常会自动修正。
  4. 最大步数限制。没有这个限制,如果模型陷入循环,程序会无限跑下去。

4.6 加入规划阶段:Plan 与 Coding 分离

上面的循环已经能完成简单任务,但遇到复杂的多文件改动时,模型容易做到一半忘了整体目标。此时可以把任务拆成两个阶段。

首先,在agent.py中增加一个规划函数:

# agent.py 追加内容 PLAN_PROMPT = ( "你是一个任务规划器。请先不要写代码,也不要调用工具。\n" "针对用户的需求,输出一份简洁的编码计划,包含:\n" "1. 目标;\n" "2. 需要查看的文件;\n" "3. 需要创建或修改的文件;\n" "4

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

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

立即咨询