从零构建天气查询Agent

  目录

最小 Agent 实战——天气查询

本实战案例使用Bun + TypeScript

创建项目及文件

用 Bun + TypeScript,零依赖:

1
2
3
4
5
6
7
mkdir agent-loop && cd agent-loop
bun init -y // 初始化bun项目

type nul > prompt.md // 创建 prompt.md 文件
type nul > tools.ts // 创建 tools.ts 文件
type nul > API_KEY.md // 创建 API_KEY.md 文件
ren index.ts main.ts // 修改入口文件名

完整的项目结构如下:

1
2
3
4
5
6
agent-loop/
├── main.ts # 核心 Agent Loop
├── tools.ts # 工具定义
├── prompt.md # 系统提示词
├── API_KEY.md # LLM密钥
└── package.json

工具定义(tools.ts)

定义两个最小集的工具:获取当前时间和查询天气。

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
export type ToolName = "getWeather" | "getTime"
export type ToolFn = (input: string) => Promise<string>

type WeatherInput = { error: string } | { city: string; time: string }

/**
* 解析 getWeather 工具的输入参数,期望输入为 JSON 字符串,包含 city 和 time 字段
* @param input 原始输入字符串
* @returns 解析后的 WeatherInput 对象,包含 city 和 time,或 error 字段描述解析错误
*/
function parseWeatherInput(input: string): WeatherInput {
try {
const parsed = JSON.parse(input)
const city = parsed?.city
const time = parsed?.time

if (!city || typeof city !== "string") {
return { error: "getWeather 需要 city 字符串" }
}

if (!time || typeof time !== "string") {
return { error: "getWeather 需要 time 字符串" }
}

return { city: city.trim(), time: time.trim() }
} catch {
return { error: 'getWeather 参数需为 JSON,如 {"city":"沈阳","time":"2026-02-27 10:00"}' }
}
}

/**
* 根据城市和时间生成模拟天气信息
* @param city 城市名称
* @param time 时间字符串,格式不限,但建议包含日期和时间信息
* @returns 天气信息字符串
*/
function buildMockWeather(city: string, time: string): string {
const conditions = ["晴", "多云", "阴", "小雨", "阵雨"]
const winds = ["东北风 2 级", "东风 3 级", "西南风 2 级", "北风 1 级"]
const seed = Array.from(`${city}|${time}`).reduce(
(acc, ch) => acc + ch.charCodeAt(0),
0,
)
const condition = conditions[seed % conditions.length] ?? "晴"
const wind = winds[seed % winds.length] ?? "微风 1 级"
const temp = 12 + (seed % 20)
const humidity = 35 + (seed % 55)
return `天气信息:${city} 在 ${time} 的天气为${condition},气温 ${temp}°C,${wind},湿度 ${humidity}%。`
}

/**
* 工具函数集合,供 Agent 调用
*/
export const TOOLKIT: Record<ToolName, ToolFn> = {
async getTime() {
return new Date().toISOString()
},

async getWeather(rawInput: string) {
const parsed = parseWeatherInput(rawInput.trim())
if ("error" in parsed) return parsed.error
return buildMockWeather(parsed.city, parsed.time)
},
}

系统提示词(prompt.md)

系统提示词告诉模型如何使用这两个工具,以及输出的格式要求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
你是天气查询的工具型助手,回答要简洁。
可用工具(action 的 tool 属性需与下列名称一致):

- getTime: 返回当前 time 字符串,参数为空。
- getWeather: 返回模拟天气信息字符串,参数为 JSON,如 {"city":"沈阳","time":"2026-02-27 10:00"}。

回复格式(严格使用 XML,小写标签):
<thought>对问题的简短思考</thought>
<action tool="工具名">工具输入</action> <!-- 若需要工具 -->
等待 <observation> 后再继续思考。
如果已可直接回答,则输出:
<final>最终回答(中文,必要时引用数据来源)</final>

规则:

- 每次仅调用一个工具;工具输入要尽量具体。
- 当用户只问“现在几点”时,优先调用 getTime。
- 查询天气时,必须调用 getWeather,并提供 city 和 time 两个字段。
- 如果拿到 observation 后有了答案,应输出 <final> 而不是重复调用。
- 未知工具时要说明,但仍用 XML 格式。
- 避免幻觉,不确定时请说明。

LLM密钥(API_KEY.md)

笔者使用的是QWen大模型

1
sk-8888888888888888888888888

核心Loop代码(main.ts)

这是整个Agent的核心——40行代码实现ReAct循环

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
import { TOOLKIT, type ToolName } from "./tools"

type Role = "system" | "user" | "assistant"

type ChatMessage = {
role: Role
content: string
}

type ParsedAssistant = {
action?: { tool: string; input: string }
final?: string
}

type QWenMessage = { content?: string }
type QWenChoice = { message?: QWenMessage }
type QWenResponse = { choices?: QWenChoice[] }

let API_KEY:string = ''
/**
* 调用 QWen API 获取模型回复
* @param messages 对话消息列表,包含 system、user 和 assistant 角色的消息
* @returns 模型生成的回复文本
* @throws 如果 API 请求失败或返回格式不正确,将抛出错误
*/
async function callLLMs(messages: ChatMessage[]): Promise<string> {
const res = await fetch("https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: "qwen3.5-plus",
messages,
temperature: 0.35,
}),
})

if (!res.ok) {
const text = await res.text()
throw new Error(`QWen API 错误: ${res.status} ${text}`)
}

const data = (await res.json()) as QWenResponse
const content = data.choices?.[0]?.message?.content
if (typeof content !== "string") {
throw new Error("QWen 返回内容为空")
}
return content
}

/**
* 解析 Assistant 回复,提取工具调用信息或最终回答
* @param content Assistant 回复的文本内容,可能包含 <action> 或 <final> 标签
* @returns ParsedAssistant 对象,包含 action(工具调用信息)或 final(最终回答)字段
*/
function parseAssistant(content: string): ParsedAssistant {
const actionMatch = content.match(
/<action[^>]*tool="([^"]+)"[^>]*>([\s\S]*?)<\/action>/i,
)
const finalMatch = content.match(/<final>([\s\S]*?)<\/final>/i)

const parsed: ParsedAssistant = {}
if (actionMatch) {
parsed.action = {
tool: actionMatch[1] as ToolName,
input: actionMatch[2]?.trim() ?? "",
}
}
if (finalMatch) {
parsed.final = finalMatch[1]?.trim()
}

return parsed
}

/**
* Agent 主循环,负责与 LLM 交互、解析回复、调用工具并更新对话历史
* @param question
* @returns 最终回答字符串,或错误提示
*/
async function AgentLoop(question: string) {
API_KEY = await Bun.file("API_KEY.md").text()
const systemPrompt = await Bun.file("prompt.md").text()

const history: ChatMessage[] = [
{ role: "system", content: systemPrompt },
{ role: "user", content: question },
]

for (let step = 0; step < 10; step++) {
const assistantText = await callLLMs(history)
console.log(`\n[LLM 第 ${step + 1} 轮输出]\n${assistantText}\n`)
history.push({ role: "assistant", content: assistantText })

const parsed = parseAssistant(assistantText)
if (parsed.final) {
return parsed.final
}

if (parsed.action) {
const toolFn = TOOLKIT[parsed.action.tool as ToolName]
let observation: string

if (toolFn) {
observation = await toolFn(parsed.action.input)
} else {
observation = `未知工具: ${parsed.action.tool}`
}

console.log(`<observation>${observation}</observation>\n`)

history.push({
role: "user",
content: `<observation>${observation}</observation>`,
})
continue
}

break // 未产生 action 或 final
}

return "未能生成最终回答,请重试或调整问题。"
}

/**
* 程序入口,读取用户问题,调用 AgentLoop 获取回答并输出
*/
async function main() {
const userQuestion = process.argv.slice(2).join(" ") || "沈阳现在天气如何?"
console.log(`用户问题: ${userQuestion}`)

try {
const answer = await AgentLoop(userQuestion)
console.log("\n=== 最终回答 ===")
console.log(answer)
} catch (err) {
console.error(`运行失败: ${(err as Error).message}`)
}
}

await main()

运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
bun main.ts # 执行bun命令

用户问题: 沈阳现在天气如何?

[LLM 第 1 轮输出]
<thought>用户询问沈阳当前天气,需要先获取当前时间,再调用天气查询工具</thought>
<action tool="getTime"></action>

<observation>2026-03-17T02:46:55.089Z</observation>


[LLM 第 2 轮输出]
<thought>已获取当前时间,现在调用 getWeather 查询沈阳天气</thought>
<action tool="getWeather">{"city":"沈阳","time":"2026-03-17 02:46"}</action>

<observation>天气信息:沈阳 在 2026-03-17 02:46 的天气为小雨,气温 20°C,东北风 2 级,湿度 83%。</observation>


[LLM 第 3 轮输出]
<final>沈阳现在(2026-03-17 02:46)的天气为小雨,气温 20°C,东北风 2 级,湿度 83%。</final>


=== 最终回答 ===
沈阳现在(2026-03-17 02:46)的天气为小雨,气温 20°C,东北风 2 级,湿度 83%。

参考

从零构建一个 Mini Claude Code:面向初学者的 Agent 开发实战指南