LLM+Function Calling开发助手Picodevil实战

📅 2026/8/27 9:36:55
LLM+Function Calling开发助手Picodevil实战
之前在一次内部工具研发中我尝试用大语言模型LLM搭建一个本地开发助手项目代号取名为 Picodevil。整体目标很直接让模型能读取本地项目文件、理解开发需求、生成代码片段并借助工具调用完成一些简单的文件查询操作。搭建过程中踩了不少坑从提示词设计到 Function Calling 的循环处理从上下文长度控制到工具执行的安全性每一步都需要重新思考。这篇文章就以 Picodevil 为例把用 LLM 搭建一个可运行开发助手的完整思路、核心代码和常见问题整理出来希望能给正在做 LLM 应用开发的读者一些参考。阅读本文后你将理解 LLM 应用的最小架构是怎样的掌握提示词模板、工具调用、上下文管理这几块关键设计并且可以直接获得一个基于 Python 的 CLI 版开发助手源码骨架。无论你是刚开始接触 LLM 应用开发的新手还是已经在做 Agent 相关项目的开发者都可以把本文的代码作为基底继续扩展。1. 背景与核心概念1.1 Picodevil 是什么Picodevil 不是一个复杂的平台它是一个运行在命令行里的轻量级开发助手。你可以通过命令行向它提问比如“帮我看看当前项目里 main.py 的功能”它就会先调用工具读取文件再基于文件内容给出结构化回答你还可以让它“生成一个读取 CSV 并统计行数的 Python 脚本”它会直接给出代码和简要说明。之所以叫 Picodevil是因为这个项目最初的定位就是“小而有点叛逆”的编程小助手。Pico 代表轻量、小巧devil 代表它不仅能回答理论问题还能真正触碰本地文件、执行一些可控操作。从产品形态上看它介于普通聊天机器人和完整 AI IDE 插件之间没有图形界面但保留了一个可以不断追加能力的工具层。这类工具解决的典型问题包括在 IDE 和网页聊天窗口之间来回切换上下文经常丢失。让模型直接读取某段代码时需要手动复制粘贴效率低。希望把公司内部代码库的查询、规范检查等流程沉淀成固定能力。需要把 LLM 接入到现有 CI/CD 或命令行工作流中。1.2 用 LLM 搭建开发工具时一定会遇到的概念要动手之前先理清几个核心概念。它们不是学术名词而是代码里真实出现的参数和模块。第一是 LLM API。大多数模型服务商会提供 HTTP 接口你传入一组消息messages和模型参数它返回模型生成的文本。消息数组里通常包含 system系统设定、user用户输入、assistant模型回复三种角色。在工具调用场景中还会多出一种 tool 角色用于把工具执行结果回传给模型。第二是 Token 与上下文窗口。Token 可以简单理解为模型处理文本的最小单位中文场景下一个汉字可能对应一个或多个 Token。模型一次能处理的输入加输出总量是有限的这个上限就是上下文窗口。Picodevil 在读取文件时必须对内容做截断原因就在这里。第三是 Function Calling函数调用/工具调用。这是让 LLM 不再局限于“聊天”的关键能力。你可以提前声明一批工具包括工具名、描述和参数结构模型在回答时会判断是否需要调用某个工具并返回结构化的调用请求包括工具名和参数 JSON。程序收到这个请求后执行真实函数再把结果作为新的消息交给模型模型再继续生成最终回答。第四是 Agent 循环。所谓 Agent简单理解就是“模型 工具 循环控制”的组合。Picodevil 的主循环就属于一个最小版 Agent模型决定调用工具程序执行工具结果回传模型继续推理直到不再请求调用工具为止。1.3 为什么不直接用现成的 AI 工具市场上已经有非常多 AI 编程助手为什么还要自己封装我的核心理由是定制化和可控性。现成产品通常运行在别人定义的交互流程里你很难让它读取特定目录下的配置文件、对接内部脚本文档、按照团队的代码规范生成内容。自己用 LLM API 封装之后工具集完全由你定义提示词可以由版本库管理调用成本也可以按接口精确统计。此外自建方案的另一个优势是可以复用企业内部已有的数据和工具。比如把接口文档、数据库 Schema、代码规范文件做成检索库再在工具调用层暴露给模型这让 LLM 输出的内容更贴近业务实际。当然自建也意味着网络、模型服务稳定性、安全边界等问题都要自己处理。这正是本文后续几个章节重点讨论的内容。2. 环境准备与版本说明2.1 运行环境Picodevil 的示例代码使用 Python 编写建议环境如下操作系统Windows 10/11、macOS、Linux 均可本文示例不依赖特定系统。Python 版本建议 3.10 或更高代码中使用了较新的类型注解和字符串写法。包管理工具pip。命令行终端系统自带终端即可。版本需要根据你的项目实际情况调整本文示例以常见环境为例重点演示配置思路。不同操作系统的 Python 安装方式不同建议使用虚拟环境隔离依赖避免污染全局环境。2.2 模型服务与 API KeyPicodevil 通过 OpenAI 兼容接口调用模型服务。也就是说不管底层是云端大模型 API还是本地部署的推理服务只要它提供/v1/chat/completions这类兼容接口Picodevil 的代码就可以直接对接。你需要准备以下信息base_url模型服务的接口地址。api_key访问密钥注意不要硬编码到源码中。model要使用的模型名称以服务商实际提供为准。不同模型对 Function Calling 的支持程度不同建议先阅读模型服务商文档确认。对于不确定的模型可以先写一个最小请求测试确认能正常返回内容后再继续开发。2.3 项目目录规划在开始写代码前先规划项目结构。Picodevil 最小版本只包含四个文件目录结构如下picodevil/ ├── config.yaml # 模型服务和运行参数配置 ├── requirements.txt # Python 依赖 ├── picodevil/ │ ├── __init__.py # 空文件标识包目录 │ ├── llm_client.py # LLM 客户端封装 │ ├── tools.py # 工具定义与实现 │ └── main.py # 命令行主循环这样的分工很清晰配置和业务分离客户端封装只负责接口通信工具层专门管理“模型能做什么”主循环负责把用户输入、模型输出、工具调用串起来。后续如果要扩展新工具只需要在 tools.py 中添加函数和 Schema。3. 核心原理拆解3.1 一次 LLM 请求的完整链路Picodevil 的核心请求逻辑非常简单本质上就是调用一次聊天补全接口。先来看最基础的请求结构from openai import OpenAI client OpenAI( base_urlhttps://api.example.com/v1, api_keysk-xxx, ) response client.chat.completions.create( modelyour-model-name, messages[ {role: system, content: 你是一个开发助手。}, {role: user, content: 解释一下什么是函数调用。}, ], temperature0.2, ) print(response.choices[0].message.content)这段代码里有几个关键参数messages对话消息列表。system 消息设定模型身份和行为user 消息是用户输入assistant 消息通常是历史回复。temperature采样温度控制输出的随机性。代码生成场景建议设低一点比如 0.2让输出更稳定。model模型名称必须与服务商提供的名称完全一致。理解这条链路很重要因为后续所有高级能力包括工具调用、多轮对话、上下文记忆都是在这个基础请求之上叠加逻辑实现的。Picodevil 的 llm_client.py 做的其实就是把这段调用封装成可复用的函数。3.2 提示词模板设计提示词是控制 LLM 行为最直接的手段。Picodevil 的 system 提示词不追求复杂但必须明确以下几点角色定义你是谁你运行在哪里。能力边界你能做什么不能做什么。输出风格回答应该简洁还是详细代码如何展示。一个比较可靠的 system 提示词模板如下你是 Picodevil一个运行在用户本地的开发助手。 你可以阅读项目文件、获取当前时间并给出代码建议。 回答要简洁、准确代码示例需要标注语言。 如果工具执行失败请如实告知用户不要编造工具结果。注意最后一句话不要编造工具结果。这个问题在实际使用中非常常见模型在工具调用失败时有时会自行补全一个看似合理的结果因此提示词中必须明确约束。另外提示词会随着功能迭代不断调整建议像管理代码一样管理提示词记录每一次改动对输出质量的影响。3.3 工具调用让 LLM 可以操作本地资源工具调用是 Picodevil 的核心。它的工作流程可以拆成四步程序把工具 Schema 列表随请求一起发给模型。模型判断当前问题是否需要调用工具。如果需要返回 tool_calls 字段里面包含工具名和参数 JSON。程序解析参数执行真实函数拿到结果。程序把工具结果作为 roletool 的消息追加到对话中再发给模型。这里最关键的是工具 Schema。每个工具必须要有清晰的 name、description 和 parameters。description 尤其重要因为模型依赖这段文字判断什么情况下该调用这个工具。比如 read_project_file 的描述是“读取指定文本文件内容用于分析项目源码”模型看到用户问“看看某个文件”就会优先选择这个工具。工具调用不一定是单轮的。模型可能先调用读取文件工具发现文件内容不够再调用另一个工具。所以主循环必须支持多轮工具调用直到模型返回普通文本为止。这个循环通常要设置最大轮数避免死循环。3.4 上下文管理不要一股脑塞给模型很多初学者在开发 LLM 应用时容易忽略上下文长度限制。Picodevil 读取文件时如果文件很大直接全量塞进消息会导致 Token 超限。更合理的做法是对文件内容做长度截断只保留前 N 个字符。对大文件先做摘要再把摘要发给模型。对多轮对话做历史压缩只保留最近的几条消息。这些操作本质上都是为了在规定上下文窗口内尽量保留有用信息。更进阶的方案是引入 RAG检索增强生成把项目文档切块向量化查询时只检索相关片段而不是把所有内容都发给模型。Picodevil 目前没有内置向量检索但在实际项目中RAG 是解决“模型不知道你的私有代码库”这个问题的主流方案。4. 完整实战Picodevil 最小可运行版本接下来我们完整实现一个 Picodevil 最小版本。这个版本包含两个工具获取当前时间、读取项目文件。代码可以直接复制到本地运行唯一需要修改的是 config.yaml 中的模型服务配置。4.1 初始化项目与依赖首先创建项目目录和虚拟环境mkdir picodevil cd picodevil python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate然后创建 requirements.txtopenai1.0.0 PyYAML6.0安装依赖pip install -r requirements.txt4.2 编写配置文件在项目根目录创建 config.yaml# 文件路径config.yaml llm: base_url: https://api.example.com/v1 api_key: sk-替换成你的密钥 model: your-model-name temperature: 0.2 max_tokens: 2048这里要特别提醒config.yaml 如果包含真实 API Key一定不要提交到 Git 仓库。建议把 config.yaml 加入.gitignore或者使用环境变量替代 api_key 字段。4.3 实现 LLM 客户端封装下面创建 picodevil/llm_client.py 文件# 文件路径picodevil/llm_client.py LLM 客户端封装统一管理模型请求。 from openai import OpenAI def build_client(cfg): 根据配置创建 OpenAI 兼容客户端。 return OpenAI( base_urlcfg[llm][base_url], api_keycfg[llm][api_key], ) def chat(client, messages, toolsNone, tool_choiceauto, cfgNone): 发送一次对话请求支持可选的工具定义。 cfg cfg or {} params { model: cfg[llm][model], messages: messages, temperature: cfg[llm].get(temperature, 0.2), max_tokens: cfg[llm].get(max_tokens, 2048), } if tools: params[tools] tools params[tool_choice] tool_choice response client.chat.completions.create(**params) return response.choices[0].message这段封装代码的好处是主循环中不需要关心 SDK 细节。如果后续要增加超时、重试、日志统一在这个文件里改即可。4.4 注册自定义工具下面创建 picodevil/tools.py 文件。这个文件包含两部分工具 Schema 定义和工具具体实现。# 文件路径picodevil/tools.py Picodevil 自定义工具把本地能力暴露给 LLM。 import os from datetime import datetime TOOL_SCHEMAS [ { type: function, function: { name: get_current_time, description: 获取当前系统时间用于回答时间相关问题。, parameters: { type: object, properties: {}, }, }, }, { type: function, function: { name: read_project_file, description: 读取指定文本文件内容用于分析项目源码。, parameters: { type: object, properties: { path: { type: string, description: 相对于项目根目录的文件路径, } }, required: [path], }, }, }, ] def get_current_time(args): 获取当前系统时间。 return datetime.now().strftime(%Y-%m-%d %H:%M:%S) def read_project_file(args, root.): 读取文件只允许访问项目根目录内的文件避免路径穿越。 rel_path args.get(path, ) full_path os.path.realpath(os.path.join(root, rel_path)) root_path os.path.realpath(root) if not full_path.startswith(root_path): return 错误不允许访问项目目录之外的路径。 if not os.path.isfile(full_path): return 错误文件不存在或不是普通文件。 try: with open(full_path, r, encodingutf-8) as f: return f.read()[:4000] except Exception as e: return f读取失败{e} TOOL_IMPL { get_current_time: get_current_time, read_project_file: read_project_file, }这里有一个容易忽略的安全细节read_project_file 使用了os.path.realpath和startswith双重校验确保模型请求的路径必须位于项目根目录内防止通过../../etc/passwd这类路径读取系统文件。虽然 Picodevil 是本地开发工具但这个防护习惯建议保留后续接入不可信输入时会非常有用。4.5 编写主循环下面创建 picodevil/main.py这是 Picodevil 的入口文件# 文件路径picodevil/main.py Picodevil 主循环LLM 工具调用。 import json import sys import yaml from llm_client import build_client, chat from tools import TOOL_SCHEMAS, TOOL_IMPL def load_config(pathconfig.yaml): with open(path, r, encodingutf-8) as f: return yaml.safe_load(f) def run_tool(name, args, project_root.): 执行模型请求的工具调用。 impl TOOL_IMPL.get(name) if impl is None: return f错误未注册的工具 {name} try: if name read_project_file: return impl(args, project_root) return impl(args) except Exception as e: return f工具执行异常{e} def main(): cfg load_config() client build_client(cfg) messages [ { role: system, content: ( 你是 Picodevil一个运行在用户本地的开发助手。 你可以阅读项目文件、获取当前时间并给出代码建议。 回答要简洁、准确代码示例需要标注语言。 如果工具执行失败请如实告知用户不要编造工具结果。 ), } ] print(Picodevil 已启动输入 exit 退出。) while True: try: user_input input( ) except (EOFError, KeyboardInterrupt): print(\n再见。) break if user_input.strip().lower() in (exit, quit): break messages.append({role: user, content: user_input}) # 工具调用循环模型可能需要多轮调用工具后才给出最终回答 for _ in range(5): reply chat(client, messages, toolsTOOL_SCHEMAS, cfgcfg) if reply.tool_calls: messages.append(reply.model_dump()) for tool_call in reply.tool_calls: fn tool_call.function try: args json.loads(fn.arguments or {}) except json.JSONDecodeError: args {} result run_tool(fn.name, args) messages.append( { role: tool, tool_call_id: tool_call.id, content: str(result), } ) else: print(AI:, reply.content) messages.append({role: assistant, content: reply.content}) break if __name__ __main__: main()主循环的核心逻辑是内层 for 循环。每次循环先调用模型如果返回 tool_calls 就执行工具并追加消息然后继续循环如果返回普通文本就打印给用户并跳出循环。外层 for 设置了 5 次上限防止模型陷入无限工具调用。需要说明的是这里的reply.model_dump()是 openai Python SDK 1.x 中消息对象转字典的方法。如果你使用的 SDK 版本较老可以换成reply.dict()。这类细节在不同版本间有差异请以你本地实际安装的版本为准。4.6 运行与验证启动 Picodevilcd picodevil python -m picodevil.main如果项目结构不是包形式也可以直接运行cd picodevil/picodevil python main.py启动后你会看到交互提示符。下面两个测试用例可以帮助验证功能 现在几点 AI: 2025-03-12 14:30:22 读取 config.yaml 并总结里面的配置 AI: config.yaml 中配置了 LLM 服务的 base_url、api_key、model、 temperature 和 max_tokens 等参数。其中 temperature 为 0.2 表示模型输出会更倾向于稳定和确定。如果你得到的回答是这个形式说明 LLM 请求、工具调用、工具结果回传这条链路已经全部打通。4.7 结果说明从运行结果可以看到Picodevil 已经具备了一个最小 LLM Agent 的全部要素有模型推理能力有工具执行能力有循环控制。当用户提出“读取 config.yaml”这类需求时模型没有直接编造文件内容而是先调用了 read_project_file 工具拿到真实内容后再总结。这正是工具调用的价值所在。后续如果要增加搜索网页、执行 SQL、调用内部 API 等能力只需要在 tools.py 中继续追加工具定义和实现即可。5. 常见问题与排查思路在实际开发 Picodevil 的过程中我遇到了不少问题。下面整理成表格方便快速定位。问题现象常见原因解决思路请求超时或连接失败base_url 配置错误或网络不通先单独测试模型接口连通性再检查 base_url 路径是否为 /v1返回内容提示超出上下文长度消息列表过长或文件内容过大截断历史消息对文件内容做长度限制必要时做摘要工具参数解析失败模型返回的 arguments 不是合法 JSON捕获 JSONDecodeError给模型回传错误信息并让它重试模型无限调用工具工具返回内容让模型误以为需要继续调用设置最大循环轮数检查工具返回结果是否清晰避免歧义工具读取到路径之外的文件路径拼接未做安全校验使用 realpath startswith 双重校验拒绝非法路径API Key 泄露到代码仓库配置直接硬编码在源码中使用环境变量或本地配置文件并加入 .gitignore除了表格里的具体问题整理一个通用的排查流程先验证最底层能力再逐步往上叠加。例如Picodevil 出现异常时我一般按以下顺序排查先用 curl 或 Python 脚本直接调用模型接口确认模型服务和 API Key 正常。再测试不带工具的普通对话确认 LLM 客户端封装没有 bug。然后测试单个工具调用确认工具的 Schema 和实现都能正常工作。最后测试多轮工具调用确认循环和消息追加逻辑正确。这个流程能帮助你快速定位问题究竟出在网络层、SDK 层、工具层还是循环控制层避免在不明根因的情况下反复修改代码。6. 最佳实践与工程建议6.1 提示词版本化与评测提示词是 LLM 应用的灵魂但它也是最容易失控的部分。我建议把提示词模板从代码中抽离出来放到独立的文件或配置项中用 Git 管理每次改动。每次修改后准备一组固定的测试用例覆盖正常请求、边界输入、工具调用失败等场景对比修改前后的输出质量。没有评测的提示词优化往往只是感觉变好了实际效果却难以追踪。6.2 异常处理与限流生产环境调用模型 API 时必须考虑异常。网络抖动、服务端限流、模型超时都是常见问题。处理思路包括对请求设置超时时间例如 30 秒。对 429限流、5xx服务端错误做指数退避重试。把每次请求的耗时、Token 消耗、错误码写入日志。在 Picodevil 中这些逻辑可以统一放在 llm_client.py 的 chat 函数里而不是散落在主循环中。6.3 安全边界LLM 应用的安全问题比传统应用更隐蔽因为模型输出不可控。Picodevil 的实践也验证了这一点。以下几点非常重要不要把 API Key 硬编码在代码或配置中优先使用环境变量。工具层必须做路径校验防止目录穿越。谨慎设计可以执行命令的工具。如果要运行 shell 命令尽量限制在白名单命令内且只能在沙箱或测试环境执行。对模型生成的代码不要直接自动执行必须先人工 review。涉及数据库、生产环境变更时工具应默认拒绝危险操作并强制二次确认。安全边界的原则是最小权限模型能访问的资源越少出错时的爆炸半径越小。6.4 成本控制与可观测性LLM API 是按 Token 计费的上下文越长、调用轮数越多成本越高。控制成本的常见手段包括精简 system 提示词去掉不必要的长文本。对历史消息做滑动窗口只保留最近 N 轮。工具返回内容不要全量回传必要时先截断。为单次会话设置最大调用轮数。可观测性同样重要。建议打印每次请求的模型、Token 数、耗时和工具调用明细。这些数据能帮助你发现哪些请求异常昂贵哪些工具调用频繁失败从而优化整体设计。6.5 从最小实现演进到 Agent 框架Picodevil 的当前版本是几百行代码的最小实现适合学习原理。但如果你要开发一个面向真实业务的项目可以考虑引入成熟的 Agent 框架或编排框架。市面上有 LangChain、LlamaIndex 等通用框架也有 Spring AI 这类面向 Java 生态的方案。它们提供了消息管理、工具注册、Agent 循环、向量检索等现成组件可以减少重复造轮子的成本。不过框架不是必须的。如果你的场景只有两三个工具自定义循环反而更可控、更轻量。建议先从最小实现跑通业务逻辑再根据复杂度决定是否引入框架。这也是 Picodevil 项目一直坚持的原则先小而快后大而全。7. 落地建议与下一步可以做什么如果你也想从零搭建一个类似 Picodevil 的开发助手我会建议你不要一开始就想做一个功能完整的产品而是先跑通最小编译闭环。哪怕只有一个工具只要模型能正确判断何时调用、工具能正确执行、结果能正确回传整个链路的价值就已经体现出来了。之后再逐步增加工具、优化提示词、引入记忆和检索每一步都基于真实使用反馈而不是靠想象堆功能。下一步值得尝试的方向有几个第一为 Picodevil 增加 RAG 能力把项目文档、接口文档转为向量索引让模型能回答“项目里 xxx 模块的调用方式是什么”这类检索型问题第二把命令行交互换成 HTTP 服务或 IDE 插件让更多场景能接入第三增加对动态工具的注册机制比如通过 JSON 配置文件声明新工具避免每次都改代码。最后留一个最实际的建议在接入任何模型能力之前先把工具的安全边界设计好。哪些路径可以读哪些命令可以执行哪些操作必须人工确认这些规则越早定下来后续迭代越省心。毕竟 LLM 的能力再强也只是通过你提供的工具触达真实世界工具层设计决定了它能做好事也决定了它能闯多大的祸。