ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于llama.cpp的极简Coding Agent实现:从零搭建本地编程助手

基于llama.cpp的极简Coding Agent实现:从零搭建本地编程助手 最近在整理本地 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 / WindowsWSL2均可本文命令以 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.cppllama.cpp 的安装方式有两种常用路径直接下载官方 release 的可执行文件或者从源码编译。源码编译适合需要针对本机 CPU 指令集优化、或者需要特定 CUDA 版本的场景git clone https://github.com/ggerganov/llama.cpp cd llama.cpp cmake -B build -DGGML_CUDAON cmake --build build --config Release -j如果你的显卡不支持 CUDA或者只想用 CPU 跑可以去掉-DGGML_CUDAONcmake -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的原理工具调用的流程可以拆成三步客户端把可用的工具列表函数名、参数 schema随消息一起发给模型。模型判断当前需要调用某个工具时不会直接回答用户而是返回一个tool_calls结构里面包含函数名和 JSON 格式的参数。客户端执行这个函数把结果作为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下面进入实战环节。我们会分四步完成启动llama-server加载 GGUF 模型。用 curl 验证接口是否可用。编写客户端封装和三个核心工具。实现 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 8080HTTP 服务端口。--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_urlBASE_URL, api_keyAPI_KEY) def chat(messages, toolsNone, tool_choiceauto, temperature0.2, max_tokens4096): 发送对话请求。 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这里有两个关键点需要解释API_KEY随便填一个非空字符串即可。llama.cpp 的本地服务不校验收费鉴权但 OpenAI SDK 会强制要求 api_key 参数存在。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, shellTrue, cwdWORKDIR, capture_outputTrue, textTrue, timeout60, ) 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: ffile not found: {path}} try: with open(full_path, r, encodingutf-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_okTrue) with open(full_path, w, encodingutf-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, toolsTOOLS) 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: funknown 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_asciiFalse), } ) print( [Step {}] 工具返回{}.format( step, json.dumps(result, ensure_asciiFalse)[:300] ) ) print(f[Agent] 超过 {MAX_STEPS} 步仍未结束强制终止。) return messages if __name__ __main__: os.makedirs(WORKDIR, exist_okTrue) user_task input(请输入要交给 coding agent 的任务).strip() if user_task: run_agent(user_task)这段代码有几个关键点assistant 消息必须保留tool_calls。后续请求里模型需要看到自己之前调用了哪些工具这是上下文一致性的一部分。工具结果用role: tool回传并带上tool_call_id和 assistant 返回的tool_calls[].id对应。格式不对会导致模型无法关联工具调用和结果。异常处理放在工具执行层。模型传入的参数不一定合法比如read_file的line_start传了字符串通过TypeError捕获并返回明确错误信息模型看到后通常会自动修正。最大步数限制。没有这个限制如果模型陷入循环程序会无限跑下去。4.6 加入规划阶段Plan 与 Coding 分离上面的循环已经能完成简单任务但遇到复杂的多文件改动时模型容易做到一半忘了整体目标。此时可以把任务拆成两个阶段。首先在agent.py中增加一个规划函数# agent.py 追加内容 PLAN_PROMPT ( 你是一个任务规划器。请先不要写代码也不要调用工具。\n 针对用户的需求输出一份简洁的编码计划包含\n 1. 目标\n 2. 需要查看的文件\n 3. 需要创建或修改的文件\n 4
返回列表