资讯动态

两天构建DeepSeek迷你Agent:深入理解Harness与工具调用机制

发布时间:2026/10/15 22:19:36 来源:尧图企业网站定制
先说结论两天时间一个没有专业 AI 背景的普通开发者能围绕 DeepSeek 玩出什么我的答案是一个能调用工具、能完成多步任务、能自己决定“下一步干什么”的迷你 Agent。它不够优雅代码也很朴素但跑通的那一瞬间我确实理解了为什么“Harness”这个词在 AI 应用层被反复提起——它不是模型本身却决定了模型到底能发挥多少能力。这篇文章是这次实验的记录不是保姆级教程。文章里出现的代码我会给出完整版本但更多篇幅会放在“为什么这样做”、“哪里容易想当然”和“普通人的两天时间到底值不值”上。如果你正想在 DeepSeek 或其他大模型之上做点什么又不知道从哪里下手这篇文章应该能帮你少走一点弯路。1. 标题里的 “Harness” 究竟是指什么先解释一下标题。Harness 的英文原意是“马具、挽具”在工程领域引申为“装配、控制装置”。放到 AI Agent 里它大约等于下面这一层东西负责替模型整理输入和上下文把模型能用的工具注册进来控制“模型要不要调用工具”以及“工具结果怎么还给模型”决定整个任务何时结束。换句话说大模型负责“思考”Harness 负责“调度”。这也是标题里“还是太权威了”想表达的意思当我把这套调度逻辑跑起来看到模型按照我的工具说明去查询天气、对比数据、最终给出连贯建议时确实会产生一种“这个框架让我服气”的感觉。不是模型突然变聪明了而是原本孤零零的对话接口终于变成了一套可以干活的系统。在没有 Harness 的时代做一个小工具只能“喂一段文字回来一段文字”。很多开发者第一个想到的是把工具结果塞进 Prompt 里再让模型总结。这种做法能跑通但本质上是“人肉串起来”模型并不知道自己正在调用工具也不会主动发起下一次查询。Harness 改变的是协作方式模型输出结构化的“工具调用请求”Harness 去执行然后把结果回传。你会发现真正的 Agent 不是模型单方面输出而是模型和外部世界之间的一问一答。谁来做这个“问”和“答”的中间层就是那套被叫做 Harness 的东西。我认为这是两天实验里最值得理解的一个点模型永远只是决策者真正让它变得有用的是那些被设计好的工具以及连接两者的控制循环。2. 两天的实验目标与技术选型在开始之前我给自己定了一个很朴素的目标第一天让 DeepSeek 回答一个结构化问题第一天下午让模型能按照约束输出工具调用第二天把对话、工具、循环封装成一个最小的 Harness最后跑一个“需要两步以上工具调用”的任务验证它不是玩具。我没有选择 LangChain 这类框架原因很简单实验是为了理解原理而不是为了快速搭一个产品。自己写循环每行代码都是可控的出了问题也知道看哪里。选型如下组件方案说明编程语言Python 3.10生态成熟调试工具多模型接口OpenAI 兼容协议DeepSeek 官方 API 支持统一调用方式依赖库openai、python-dotenvopenai 库负责请求细节dotenv 管理密钥工具定义原生的 tools 参数让模型用结构化的方式表达“想调用哪个工具”Harness 本体自己实现核心循环只有几十行越薄越好如果只看材料可能会觉得两天时间很充裕。实际上大部分时间都花在“安全地验证每一步”上先确认网络请求通再确认工具能被识别再确认工具结果不会被模型曲解。真正写 Harness 核心循环可能只花了一个多小时。这也是我想在文章开头强调的普通人玩两天能做出的不是一个复杂产品而是一条完整的、可以被解释的技术路径。这条路径比最终的代码更值钱。3. 第一天上午让 DeepSeek 先“开口说话”3.1 准备环境与密钥我建了一个新的目录用虚拟环境隔离依赖mkdir deepseek-harness-demo cd deepseek-harness-demo python -m venv .venv source .venv/bin/activate pip install openai python-dotenv在项目根目录创建.env文件内容如下# .env DEEPSEEK_API_KEYsk-your-key DEEPSEEK_BASE_URLhttps://api.deepseek.com DEEPSEEK_MODELdeepseek-chat这里有一个小坑如果直接照搬 OpenAI 的默认地址写成了https://api.deepseek.com/v1部分接口版本可能二次拼接路径导致请求失败。建议先按官方文档确认 Base URL而不是靠记忆。另外.env文件千万不要提交到 Git。我习惯在同一个目录里放一个.gitignore把.env、.venv、__pycache__全部忽略掉# .gitignore .env .venv/ __pycache__/3.2 第一次调用验证模型可用第一次调用我尽量写最简单只验证“能不能通”# day1_quick_test.py import os from dotenv import load_dotenv from openai import OpenAI load_dotenv() client OpenAI( api_keyos.getenv(DEEPSEEK_API_KEY), base_urlos.getenv(DEEPSEEK_BASE_URL), ) resp client.chat.completions.create( modelos.getenv(DEEPSEEK_MODEL, deepseek-chat), messages[ {role: user, content: 请用一句话解释什么是 Agent并给出一个例子。} ], temperature0.7, ) print(resp.choices[0].message.content)运行方式python day1_quick_test.py能正常打印出一段解释第一步就通了。在这个阶段其实已经能感受到 API 调用的基本逻辑一个messages列表、一个模型名字、一个choices[0].message.content的输出位置。但当时我并不满足。因为这种“一问一答”距离我想要的 Agent 还很远。第二天的工作重点是让模型不要只输出文字而是输出“我想调用哪个工具”的结构化指令。4. 第一天下午给模型接上工具从对话走向执行4.1 为什么必须做函数调用直觉上让模型调用工具可能有很多方式比如让模型输出一行 JSON告诉程序“我要调用 get_weather”或者干脆用正则匹配关键词。这些方法都有明显的短板。用正则匹配模型表达力有限稍微换个说法就失效让模型自由输出 JSON格式不稳定键名和类型不可控没有标准协议就无法在多次调用之间保持一致。DeepSeek API 提供了一套更优雅的机制使用tools参数定义工具模型如果有调用意图会在响应里返回tool_calls。这个字段的数据结构是固定的程序可以直接解析不需要猜。4.2 定义工具并解析调用意图我用“查询城市天气”作为第一个实验工具。这个场景非常简单最适合验证链路。# day1_tool_demo.py import json import os from dotenv import load_dotenv from openai import OpenAI load_dotenv() client OpenAI( api_keyos.getenv(DEEPSEEK_API_KEY), base_urlos.getenv(DEEPSEEK_BASE_URL), ) # 给模型看的工具说明重点在 description它会直接影响模型判断 weather_tool { type: function, function: { name: get_city_weather, description: 查询指定城市的当前天气用于出行建议, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如杭州 } }, required: [city] } } } resp client.chat.completions.create( modeldeepseek-chat, messages[ {role: user, content: 杭州现在天气怎么样适合出门跑步吗} ], tools[weather_tool], tool_choiceauto, ) msg resp.choices[0].message # 关键判断如果模型决定调用工具tool_calls 不会为空 if msg.tool_calls: for call in msg.tool_calls: print(工具名:, call.function.name) print(参数:, call.function.arguments) print(调用ID:, call.id) else: print(模型没有调用工具直接回答, msg.content)这是第一次运行时的输出应该是类似这样的结构工具名: get_city_weather 参数: {city: 杭州} 调用ID: call_xxxx看到这段输出后我立刻明白了两件事。第一模型确实“读懂了”工具定义并且按照 JSON Schema 格式传参连字段名都和定义里一致。这意味着良好的工具描述设计比任何关键词匹配都可靠。第二模型并没有擅自编造天气数据而是选择把问题交给工具去解决。这是 Agent 和普通聊天机器人最核心的区别也是我后来写 Harness 循环的心理基础。需要强调的是tool_choice可以有三个取值auto让模型自己判断是否调用工具none强制不调用工具{type: function, function: {name: xxx}}强制指定某个工具。实验阶段auto最合适因为它最接近真实用户使用时的状态。5. 第二天把碎片拼成最小 Harness5.1 Harness 的最小定义当我拿到工具调用结果后自然想到一个问题如果只调用一次工具结果并不算完整。比如用户问“杭州和上海天气怎么样适合跑步吗”模型需要先分别查询两个城市再综合给出建议。这就需要循环模型第一次输出工具调用 → 程序执行工具 → 把结果以tool角色的消息回传给模型 → 模型继续判断要不要再调用下一个工具或者输出最终回答。这套循环就是我理解的 Harness 雏形。一个最小 Harness 至少包含四块工具注册表把可调用的函数映射到模型能读懂的 JSON 定义消息管理维护user、assistant、tool三种角色的消息工具分发根据tool_calls里的名字找到实际函数并执行终止条件模型不再返回工具调用或者达到最大轮次。需要注意tool_calls里的参数是一个 JSON 字符串而不是字典。很多人在这一步报错就是因为没有json.loads。我就是用两天的实际教训把这个细节钉在了脑子里参数必须先json.loads再传给本地函数执行完的结果再json.dumps成字符串回传。5.2 核心循环代码下面这个文件就是整个实验的核心我给它取名harness_demo.py。# day2_harness_demo.py import json import os from dotenv import load_dotenv from openai import OpenAI load_dotenv() client OpenAI( api_keyos.getenv(DEEPSEEK_API_KEY), base_urlos.getenv(DEEPSEEK_BASE_URL), ) # 工具定义真实项目中这里的函数可以从本地服务、数据库、第三方 API 读取数据 WEATHER_TOOLS [ { type: function, function: { name: get_city_weather, description: 查询指定城市的天气、温度和风力适合做出行建议, parameters: { type: object, properties: { city: {type: string, description: 城市名称例如杭州} }, required: [city] } } } ] # 为了演示方便用临时模拟数据代替真实天气服务。 # 只是为了让环境不依赖外网。若要接入真实数据替换这个函数即可。 def get_city_weather(city: str) - dict: mock_data { 杭州: {temperature: 18, wind: 3, condition: 多云}, 上海: {temperature: 20, wind: 2, condition: 晴} } return mock_data.get(city, {temperature: None, wind: None, condition: 未知城市}) def call_tool(name: str, arguments: str): 根据模型返回的工具名分发到具体函数 args json.loads(arguments) if name get_city_weather: return get_city_weather(**args) raise ValueError(f未知工具: {name}) def run_harness(user_input: str, max_steps: int 5) - str: messages [{role: user, content: user_input}] for step in range(max_steps): print(f\n--- 第 {step 1} 轮 ---) resp client.chat.completions.create( modeldeepseek-chat, messagesmessages, toolsWEATHER_TOOLS, tool_choiceauto, ) message resp.choices[0].message # 没有工具调用说明模型准备输出最终回答 if not message.tool_calls: final_answer message.content or 模型未返回内容 print(最终回答, final_answer) return final_answer # 把 assistant 消息原样追加保证后续对话上下文完整 messages.append({ role: assistant, content: message.content or , tool_calls: [call.model_dump() for call in message.tool_calls] }) # 逐个执行工具把结果以 tool 消息回传 for tool_call in message.tool_calls: result call_tool(tool_call.function.name, tool_call.function.arguments) print(f执行工具 {tool_call.function.name}参数 {tool_call.function.arguments}结果 {result}) messages.append({ role: tool, tool_call_id: tool_call.id, content: json.dumps(result, ensure_asciiFalse) }) return 达到最大轮次任务未完成建议调整提示词或增加轮次上限 if __name__ __main__: # 一个需要多步工具调用的任务 question 我想从杭州去上海跑步两地的天气分别适合吗给出建议。 run_harness(question)这段代码的核心逻辑就一句话模型每一步要么调用工具要么输出最终回答。程序只需要循环判断这两种状态。这里最需要注意的是assistant消息的结构。如果你漏掉了tool_calls下一次请求时模型会失去对“之前自己调用了哪些工具”的记忆进而可能重复调用或者逻辑混乱。我在实验第一天就踩了这个坑只追加了文本内容结果模型第二次继续猜测而不是基于工具数据总结。5.3 一个完整的执行流程运行上面的脚本控制台会分轮输出--- 第 1 轮 --- 执行工具 get_city_weather参数 {city: 杭州}结果 {temperature: 18, wind: 3, condition: 多云} --- 第 2 轮 --- 执行工具 get_city_weather参数 {city: 上海}结果 {temperature: 20, wind: 2, condition: 晴} --- 第 3 轮 --- 最终回答 杭州多云温度18℃风力3级适合慢跑但要注意防风上海晴天温度20℃风力2级比较适合跑步。如果你习惯在杭州跑建议穿防风外套……这个流程最大的特点是模型不会一次输出两个城市的结果而是先把两个城市的工具调用“分配到”不同的轮次里去执行。原因可能在于模型在对话历史里逐步看到了结果再决定下一步需要什么。也就是说Harness 让“不确定性”被拆成了一个可控的序列每次决策只关于“下一步做哪一个动作”而不是一次性给出完整结论。这个机制我觉得就是 Agent 能给人“权威感”的真正来源——它看起来像在“按计划办事”。6. 跑出来的结果模型、工具与控制循环如何协作跑通之后我又测试了几组不同的任务主要关注三个维度模型是否能正确拆解多步任务工具执行结果返回后模型是否真的参考了数据遇到工具返回空值时模型是直接编造还是诚实表达。第一点没有问题。让模型“查询北京和上海的天气对比后决定去哪个城市跑步”它会自动发起两次工具调用最后再做总结。这种自然的任务拆解能力比我预想的强很多。第二点模型确实是基于工具数据做总结的。在工具返回“杭州多云、18度、风力3级”后它不会凭空说“杭州正在下雨”。这印证了一个判断只要 Harness 回传的数据足够结构化模型对数据的依赖程度会远高于对自身知识的依赖。第三点是难点。当我把天气工具返回改成“未知城市”并询问一个不存在的城市时部分测试结果里模型会有点兜不住偶尔会“猜”一个可能天气而不是直接说“我没有查询到数据”。这种现象说明Harness 不仅要保证工具能跑通还要给模型提供足够明确的返回值语义。比如工具在查不到数据时返回{error: city not found}再配一句“如果结果是 error请告知用户查询失败”会比单纯返回空值可靠得多。这也让我意识到一个 Agent 的稳定性并不完全取决于模型能力很大部分取决于工具定义、返回格式和系统提示词的配合。Harness 里的工具描述写得好不好直接决定模型“指挥”得对不对。7. 踩坑记录与排查思路两天实验里遇到的问题不少选几个有代表性的列出来。问题现象可能原因排查方式解决方案调用接口返回 401 或 403API Key 错误或环境变量没加载先打印os.getenv(DEEPSEEK_API_KEY)前几位检查.env文件和load_dotenv()调用位置模型没有返回tool_calls而是直接输出文字回答工具描述不清楚或模型认为可以直接回答查看返回消息内容确认模型是否误解了工具能力优化工具 description必要时使用tool_choice强制调用tool_calls参数无法解析参数是 JSON 字符串而不是字典打印type(arguments)确认类型先json.loads再传入函数并处理异常下一次请求报错带tool_calls的消息格式不对assistant消息里漏掉了tool_calls字段检查追加到 messages 的字典里是否包含该字段完整保留模型返回的tool_calls结构模型重复调用同一个工具上下文里没有清晰展示历史工具结果检查对话历史是否包含tool角色的消息确保每次工具执行后都追加结果并且tool_call_id对应正确上下文越来越长甚至超过模型长度限制没有限制历史轮次打印messages数量对历史做截断例如只保留最近 10 条消息或摘要历史工具返回结果被模型忽略返回的 content 不是字符串或者格式不清晰打印回传的 content统一用json.dumps(..., ensure_asciiFalse)转成字符串这里重点说说第五个问题。第一次实验时我偷懒只把工具结果追加成了普通assistant文本没有按tool角色和tool_call_id回传。结果模型第二轮又调了一次相同工具像是完全没看到刚才的数据一样。后来把结构改成标准的三段式——assistant含 tool_calls→tool含 tool_call_id→assistant最终回答逻辑就正常了。这个细节看起来很小但就是它决定了“失控的工具调用”和“稳定的 Agent 循环”之间的分界线。8. 普通人的两天实验到底换来了什么如果从产品角度看两天做不出一个能上线的 Agent。模型选型、工具稳定性、权限边界、日志监控每一项都需要更多时间打磨。但如果从理解和验证的角度看两天刚好够用。我得到的最重要收获不是某一段代码而是三句话Agent 的能力上限由 Harness 决定模型只是其中一个部件工具调用的稳定性取决于工具描述、返回格式和消息结构而不是模型的“聪明程度”普通人也能用很少的代码把 DeepSeek 从“聊天对手”变成“会按计划执行任务的协作者”。如果接下来要继续深入我建议你可以按这几条路径逐步升级给工具调用加上重试和超时机制避免外部服务异常导致循环中断在 Harness 里加入统一日志记录每轮模型决策、工具入参出参方便问题回溯把工具执行权限收窄比如只允许调用白名单函数防止模型因为提示词被污染而调用危险操作为不同的任务准备不同的系统提示词让模型在“总结方案”和“执行动作”之间切换不要急着上 LangChain 这类厚重框架先在这份代码上迭代足够理解后再迁移到生产框架。那些看代码觉得“不过如此”的人可能没意识到这个小循环背后承载的是一整套 Agent 的骨架。普通人玩两天做到这个程度就已经是很好的起点了。最后说一句把那几行核心循环放进自己的项目里跑两遍你会发现 DeepSeek Harness 带来的“权威感”其实是一种把大模型接入真实世界之后才会有的掌控力。这种掌控力不需要多么高深的理论只需要你想清楚每一步谁在决策、谁在执行然后让循环转起来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑