本文整理自 CNB 上的开源课程 《Vibe Agent:从零搭建 Coding Agent》。课程讲义、示例代码与练习仓库均在 CNB 上公开,文中示例出自该仓库对应章节。

问模型现在几点,它给不出准确答案。原因在训练数据:这类信息从来没有出现过。把模型的边界划清楚,才能决定该在提示词里放什么、该给它配什么工具。

四类够不到的信息

课程把模型的处境比作一个读遍图书馆、却从未出过门的人。有四类信息它无法从自身获得:

类型 例子
实时信息 当前时间、今天的天气、某只股票的现价
私域知识 公司内部文档、仓库里的代码
训练截止之后的事 最新的新闻、论文、政策
执行结果 一段代码实际输出什么、一次 API 调用返回什么

参数是训练语料的压缩结果。训练结束之后发生的事、没有公开过的文档、需要动手运行才知道的结果,都不在这份压缩里。模型规模再大也补不上,缺口来自信息本身不在训练分布中,与推理能力无关。

缺口还有一种表现。被问到超出知识范围的内容时,模型没有”拒答”的强制机制,倾向于给出最像的续写,于是答案格式完整、内容错误。这类输出通常被称为幻觉。遇到模型胡说时,先看问题落在上面四类里的哪一类,多数情况能直接定位。

模型能用的信息全部来自参数与这次传入的提示词,二者之外没有第三个来源。参数固定,所以它感知不到时间的变化。

在 system 里写”请回答当前时间”也不会让模型获得一只时钟。它只会更努力地猜一个像样的时间,而猜出来的数字和真实时间没有关系。

第一条路:把资料拼进提示词

最直接的做法是把外部资料写进 user 消息,让模型基于资料回答。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import os
from openai import OpenAI

client = OpenAI(base_url=os.environ["BASE_URL"], api_key=os.environ["API_KEY"])
MODEL = os.environ.get("MODEL_ID", "deepseek-v4.1-flash")

with open("internal_policy.md", encoding="utf-8") as f:
internal_doc = f.read()

messages = [
{"role": "user", "content": f"""请依据下面的内部文档回答问题。

【文档】
{internal_doc}

【问题】
报销流程是什么?
"""},
]
resp = client.chat.completions.create(model=MODEL, messages=messages, temperature=0)
print(resp.choices[0].message.content)

提示词里放了多少,模型就能用多少。这是 RAG 的最朴素形态:检索由人完成,增强是把文档塞进消息,生成是模型基于文档作答。三个词对应三步,程序没有替人做检索。

好处是门槛低。任何支持 chat 接口的模型都能用,不需要额外的调度逻辑,一份文档加一段问题就能验证。局限同样明显:塞哪份资料要由人决定;资料一大就撞上下文窗口;模型不会在需要时主动去找下一份文档。

第二条路:让模型自己选工具

更彻底的做法是把选择权交给模型。程序先声明有哪些工具可用,模型在需要时输出一次工具调用,程序执行后把结果回传,再由模型组织语言。流程可以拆成四步:用户提问;模型输出工具名与 JSON 参数;程序执行工具并拿到结果;结果以 role 为 tool 的消息回传,模型生成最终回答。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import datetime
import os
from openai import OpenAI

client = OpenAI(base_url=os.environ["BASE_URL"], api_key=os.environ["API_KEY"])
MODEL = os.environ.get("MODEL_ID", "deepseek-v4.1-flash")

def get_current_time() -> str:
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")

TOOLS = [{
"type": "function",
"function": {
"name": "get_current_time",
"description": "获取当前本地时间",
"parameters": {"type": "object", "properties": {}},
},
}]

messages = [{"role": "user", "content": "现在几点?"}]
msg = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS).choices[0].message
messages.append(msg)

if msg.tool_calls:
call = msg.tool_calls[0]
messages.append({"role": "tool", "tool_call_id": call.id, "content": get_current_time()})
final = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS)
print(final.choices[0].message.content)

两个细节容易出错。第二轮请求要把带 tool_calls 的 assistant 消息原样带回,每条工具结果都要配上对应的 tool_call_id,少一样模型就接不上这次调用。工具能否被选中,取决于 name、description、parameters 三段文字,描述含糊时模型会选错工具,或者干脆不用。

工具返回的内容同样计入上下文。读一次日志或一个大文件,可能吃掉窗口的大半,工具设计因此要控制返回长度。

一次工具调用要经过两次请求:第一次让模型决定调什么,第二次让它用结果作答。轮次比单次调用多,成本也更高,Agent 循环必须设上限,原因之一就在这里。

两种方式怎么分工

把资料塞进提示词 让模型自己调工具
谁决定查什么 开发者或使用者 模型自己
灵活性 低,查什么写在代码里 高,按需选择
复杂度 极低 需要实现工具调度
适用场景 固定文档问答 通用 Agent

实际工程里两条路常一起用。检索把与问题最相关的资料放进上下文打底,工具负责补齐检索覆盖不到的实时数据和执行动作。

工具不是权限边界

有一处边界需要单独说明。工具的 description 只影响模型如何选择,不产生约束力。以常见的命令行工具为例,一个 cwd 参数只是设定默认工作目录,模型传绝对路径就能绕开,把它当成安全边界是误判。真正需要禁掉的操作要在执行层拦:参数校验、允许清单、危险动作的人工确认。system prompt 里写”不要执行危险命令”,模型通常遵守,但这是倾向,不是保证。

对搭 Agent 意味着什么

Agent 由三部分构成:模型、一组工具、自主决定用哪个工具的能力。围绕这三部分的工作也很具体:配哪些工具,怎么写描述让模型选对,怎么管理上下文不让它溢出。工具描述写得好不好,直接决定模型的选择质量,这段文字实质上也是提示词的一部分。

第 1 章的四节拼在一起,回答了 LLM 是什么、能做什么、不能做什么。第 2 章把上面那条手工流程固化下来,也就是 Function Calling 协议:模型自己选工具、调工具、用结果作答。