Martin Fowler:编程中的 LLM Prompt 实战指南
业界权威系统讲解如何在编程实践中有效使用 LLM,包含具体 prompt 技巧和设计模式,高质量参考资料。
业界权威系统讲解如何在编程实践中有效使用 LLM,包含具体 prompt 技巧和设计模式,高质量参考资料。
这是我对与徐浩的一次内部对话的记录,他展示了如何利用 ChatGPT 生成有用的自测代码。他最初的提示词通过实现策略(链式思维提示)来引导 LLM。他的提示词还要求生成实现计划而非代码(通用知识提示)。一旦有了计划,他会用它来完善实现方式并生成有用的代码片段。
最近我观看了一场有趣的 Zoom 会议。Thoughtworks 中国技术负责人徐浩解释了他在使用 ChatGPT 帮助构建自测代码方面的探索。他展示了对他有效的交互方式。
他首先使用一个提示词来设置应用程序的上下文以及他希望代码的结构方式。
当前系统是一个在线白板系统。技术栈:typescript、react、redux、konvajs 和 react-konva。测试工具为 vitest、react testing library(用于模型、视图模型及相关 hooks)、cypress component tests(用于视图)。
所有代码应使用上述技术栈编写。需求应作为 React 组件在 MVVM 架构模式中实现。
系统中有 2 种类型的视图模型。
共享视图模型。代表本地和远程用户之间共享状态的视图模型。
共享视图模型。代表本地和远程用户之间共享状态的视图模型。
本地视图模型。代表仅适用于本地用户的状态的视图模型。
本地视图模型。代表仅适用于本地用户的状态的视图模型。
以下是常见的实现策略:
共享视图模型实现为 Redux store 分片。在 vitest 中测试。
共享视图模型实现为 Redux store 分片。在 vitest 中测试。
本地视图模型实现为 React 组件 props 或状态(通过 useState hook),除非是全局本地视图模型,也实现为 Redux store 分片。在 vitest 中测试。
本地视图模型实现为 React 组件 props 或状态(通过 useState hook),除非是全局本地视图模型,也实现为 Redux store 分片。在 vitest 中测试。
Hooks 用作主要的视图助手,用于从共享视图模型中检索数据。大多数情况下,它将使用 'createSelector' 和 'useSelector' 进行记忆化。在 vitest 和 react testing library 中测试。
Hooks 用作主要的视图助手,用于从共享视图模型中检索数据。大多数情况下,它将使用 'createSelector' 和 'useSelector' 进行记忆化。在 vitest 和 react testing library 中测试。
不要直接分发 action 来改变共享视图模型的状态,而应使用一个封装的视图模型接口。在该接口中,每个 redux action 都映射到一个方法。在 vitest 中测试。
不要直接分发 action 来改变共享视图模型的状态,而应使用一个封装的视图模型接口。在该接口中,每个 redux action 都映射到一个方法。在 vitest 中测试。
视图由 Konva 形状组成,通过 react-konva 作为 React 组件实现。在 cypress component tests 中测试。
视图由 Konva 形状组成,通过 react-konva 作为 React 组件实现。在 cypress component tests 中测试。
实现和测试组件时应遵循以下特定模式
编写测试时,使用 describe 而不是 test
编写测试时,使用 describe 而不是 test
数据驱动测试是首选。
数据驱动测试是首选。
测试视图组件时,通过视图模型接口模拟视图模型
测试视图组件时,通过视图模型接口模拟视图模型
在白板上显示其他用户的感知信息(光标、名称和在线信息)。
AC1:不显示本地用户
AC2:当远程用户改变光标位置时,用动画显示该改变。
根据上述指导原则提供整体解决方案。提示:将所有感知信息保留在 Konva 图层中,以及用于渲染光标和名称的感知信息组件。不要生成代码。描述解决方案,并根据上述指导原则将解决方案分解为任务列表。我们将此任务列表称为主计划。
这个提示词包含了很多内容,所以他重点强调了几个要点。
他在这里使用了一个通用的应用程序示例:与 ChatGPT 等工具交互时需要谨慎的一点是,我们不应该将任何可能机密的信息放入提示词中,因为那会带来安全风险。业务规则、任何来自真实项目的代码——所有这些都不应该进入与 ChatGPT 的交互中。
提示词的大部分内容是阐述他希望 ChatGPT 生成的代码遵循的设计指南。他通过向 ChatGPT 输入提示词、查看结果并精打细磨提示词来改进这个过程,使其能够按照他想要的风格进行生成。一旦上下文工作正常,他就可以将其粘贴到他与 ChatGPT 的每个会话中,并与团队的其他成员分享。
徐浩通过使用上下文和生成计划的指令来开始他与 ChatGPT 的会话
尽管此次会话的最终目标是生成代码及其测试,但在这个阶段他特别说明不要生成代码。此时他希望 ChatGPT 先概述其方法,然后再进行任何代码生成。这是一种称为生成知识的提示技术。
与 LLM 交互的常见方式是给它一个问题并要求它生成某个最终产品,无论是散文还是(在这个例子中)代码。通过生成知识,我们将交互分为几个独立的步骤:首先要求 LLM 生成一些关于问题的有用信息,然后我们将这些信息反馈给 LLM 以生成最终产品。
提示词中最有趣的部分是"实现策略"部分,它结合了两种提示风格:
指令提示提供 LLM 需要遵循的指令
链式思维提示鼓励 LLM 解释其推理过程。
在实现策略部分,徐浩将所需的架构模式描述为 ChatGPT 应遵循的预期"链式思维指令"。然后他指示 ChatGPT 根据这种链式思维构建任务列表(生成知识)。
为了实现感知层功能,我们可以遵循问题陈述中提到的指南,并使用现有的技术栈。
创建一个名为"AwarenessInfo"的新组件来为远程用户渲染光标和名称。
创建一个名为"AwarenessInfo"的新组件来为远程用户渲染光标和名称。
创建一个新的 Konva 图层来保存所有"AwarenessInfo"组件。
创建一个新的 Konva 图层来保存所有"AwarenessInfo"组件。
将 Konva 图层添加到主白板组件。
将 Konva 图层添加到主白板组件。
创建一个新的 Redux slice 来存储远程用户的感知信息。
创建一个新的 Redux slice 来存储远程用户的感知信息。
为感知 Redux slice 创建一个封装的视图模型接口。
为感知 Redux slice 创建一个封装的视图模型接口。
实现"AwarenessInfo"组件以从视图模型中检索和显示每个远程用户的感知信息。
实现"AwarenessInfo"组件以从视图模型中检索和显示每个远程用户的感知信息。
在封装的视图模型接口中实现一个方法来更新每个远程用户的感知信息。
在封装的视图模型接口中实现一个方法来更新每个远程用户的感知信息。
当远程用户改变光标位置时,在封装的视图模型接口中分发 update 方法来更新对应的感知信息。
当远程用户改变光标位置时,在封装的视图模型接口中分发 update 方法来更新对应的感知信息。
在"AwarenessInfo"组件中,使用新位置来对光标改变进行动画处理。
在"AwarenessInfo"组件中,使用新位置来对光标改变进行动画处理。
为新的"AwarenessInfo"组件编写测试,包括数据驱动测试以确保它为所有远程用户正确显示感知信息。
为新的"AwarenessInfo"组件编写测试,包括数据驱动测试以确保它为所有远程用户正确显示感知信息。
为封装的视图模型接口编写测试以确保它为远程用户正确更新感知信息。
为封装的视图模型接口编写测试以确保它为远程用户正确更新感知信息。
编写端到端 Cypress 测试以确保当远程用户改变光标位置时,感知层被正确显示和更新。
编写端到端 Cypress 测试,确保当远程用户改变光标位置时,感知层能够正确显示并更新。
通过完成这些任务,我们就可以在在线白板系统中实现感知层功能。
ChatGPT 的响应具有非确定性,因此这个结果只是其中一次运行的产物。如果你(或者我)再次输入这个提示词,我们将得到不同的响应。
该响应的关键部分是一份有序任务列表,其中每个步骤都有编号。在接下来的整个会话中,我们可以使用这些编号来引用对应的任务。
LLM 给出了一份计划。
现在,徐昊可以查看总体计划,判断它是否合理。如果其中存在问题,他可以通过提供更多信息,让 ChatGPT 重写部分任务或整个总体计划。
例如,当前的总体计划并没有按照不同类型的组件进行分组。一个可行的提示词可能是:
重写总体计划,并将任务分为模型、视图模型和视图三组。
这有助于验证架构构想与总体计划是否一致。
可以将这些调整加入初始提示词,从而获得整体上更好的响应。对于这个特定的提示词,下面这条指令并未包含在初始提示词中,而是在后续迭代时添加的:
4. 不要直接分发 action 来更改共享视图模型的状态,
而应使用封装后的视图模型接口。在该接口中,
每个 redux action 都会映射为一个方法。使用 vitest 进行测试。
在总体计划看起来足够合理后,徐昊要求重新生成计划,并为其中的程序元素指定名称。
当然,下面是一份经过修订的总体计划,其中包含详细的组件名称、方法和 props:
创建一个名为 "AwarenessInfo" 的新组件,用于渲染远程用户的光标和姓名。该组件将接收用户名、光标位置和在线状态等 props。
创建一个名为 "AwarenessInfo" 的新组件,用于渲染远程用户的光标和姓名。该组件将接收用户名、光标位置和在线状态等 props。
创建一个名为 "AwarenessLayer" 的新 Konva 图层,用于容纳所有 "AwarenessInfo" 组件。
创建一个名为 "AwarenessLayer" 的新 Konva 图层,用于容纳所有 "AwarenessInfo" 组件。
将 "AwarenessLayer" 添加到主白板组件中。
将 "AwarenessLayer" 添加到主白板组件中。
创建一个名为 "awareness" 的新 Redux slice,用于存储远程用户的感知信息。
创建一个名为 "awareness" 的新 Redux slice,用于存储远程用户的感知信息。
为 "awareness" Redux slice 创建一个名为 "AwarenessViewModel" 的封装视图模型接口。该接口将包含一个名为 "updateUserAwarenessInfo" 的方法,用于更新每个远程用户的感知信息。
为 "awareness" Redux slice 创建一个名为 "AwarenessViewModel" 的封装视图模型接口。该接口将包含一个名为 "updateUserAwarenessInfo" 的方法,用于更新每个远程用户的感知信息。
实现 "AwarenessInfo" 组件,使其能够从视图模型中获取并显示每个远程用户的感知信息。该组件将使用 "useSelector" hook 获取相关信息。
实现 "AwarenessInfo" 组件,使其能够从视图模型中获取并显示每个远程用户的感知信息。该组件将使用 "useSelector" hook 获取相关信息。
在 "AwarenessViewModel" 接口中实现 "updateUserAwarenessInfo" 方法,通过分发 Redux action 更新每个远程用户的感知信息。该方法将由 "MainWhiteboard" 组件调用。
在 "AwarenessViewModel" 接口中实现 "updateUserAwarenessInfo" 方法,通过分发 Redux action 更新每个远程用户的感知信息。该方法将由 "MainWhiteboard" 组件调用。
当远程用户改变光标位置时,调用 "AwarenessViewModel" 接口中的 "updateUserAwarenessInfo" 方法,以更新对应的感知信息。
当远程用户改变光标位置时,调用 "AwarenessViewModel" 接口中的 "updateUserAwarenessInfo" 方法,以更新对应的感知信息。
在 "AwarenessInfo" 组件中,使用新位置为光标变化添加动画。这可以通过 Konva 的 "Tween" 动画实现。
在 "AwarenessInfo" 组件中,使用新位置为光标变化添加动画。这可以通过 Konva 的 "Tween" 动画实现。
为新的 "AwarenessInfo" 组件编写测试,包括数据驱动测试,以确保它能正确显示所有远程用户的感知信息。这些测试将使用 React Testing Library 编写。
为新的 "AwarenessInfo" 组件编写测试,包括数据驱动测试,以确保它能正确显示所有远程用户的感知信息。这些测试将使用 React Testing Library 编写。
为封装后的视图模型接口编写测试,以确保它能正确更新远程用户的感知信息。这些测试将使用 Vitest 框架。
为封装后的视图模型接口编写测试,以确保它能正确更新远程用户的感知信息。这些测试将使用 Vitest 框架。
编写端到端 Cypress 测试,确保当远程用户改变光标位置时,感知层能够正确显示并更新。测试将模拟用户与白板的交互,并验证 "AwarenessLayer" 和 "AwarenessInfo" 组件的状态。
编写端到端 Cypress 测试,确保当远程用户改变光标位置时,感知层能够正确显示并更新。测试将模拟用户与白板的交互,并验证 "AwarenessLayer" 和 "AwarenessInfo" 组件的状态。
此时,徐昊已经准备好让 ChatGPT 为其中一个步骤提供代码:
当然,让我们先从为 awareness slice 和 AwarenessViewModel 编写测试开始:
Awareness Slice 的测试:
describe('awareness slice', () => {
let store: MockStore;
beforeEach(() => {
store = configureMockStore()(initialState);
});
it('should set the cursor location', () => {
store.dispatch(setCursorLocation({ x: 100, y: 200 }));
const state = store.getState().awareness;
expect(state.cursorLocation).toEqual({ x: 100, y: 200 });
});
it('should set the name', () => {
store.dispatch(setName('John Doe'));
const state = store.getState().awareness;
expect(state.name).toBe('John Doe');
});
it('should set the online status', () => {
store.dispatch(setOnline(true));
const state = store.getState().awareness;
expect(state.isOnline).toBe(true);
});
});
AwarenessViewModel 的测试:
describe('AwarenessViewModel', () => {
let store: MockStore;
let awarenessViewModel: AwarenessViewModel;
beforeEach(() => {
store = configureMockStore()(initialState);
awarenessViewModel = new AwarenessViewModel(store);
});
it('should set the cursor location', () => {
awarenessViewModel.setCursorLocation({ x: 100, y: 200 });
const state = store.getState().awareness;
expect(state.cursorLocation).toEqual({ x: 100, y: 200 });
});
it('should set the name', () => {
awarenessViewModel.setName('John Doe');
const state = store.getState().awareness;
expect(state.name).toBe('John Doe');
});
it('should set the online status', () => {
awarenessViewModel.setOnline(true);
const state = store.getState().awareness;
expect(state.isOnline).toBe(true);
});
});
现在,让我们实现 Awareness slice 和 AwarenessViewModel:
Awareness slice:
interface AwarenessState {
cursorLocation: { x: number; y: number };
name: string;
isOnline: boolean;
}
const initialState: AwarenessState = {
cursorLocation: { x: 0, y: 0 },
name: '',
isOnline: false,
};
const awarenessSlice = createSlice({
name: 'awareness',
initialState,
reducers: {
setCursorLocation: (state, action: PayloadAction<{ x: number; y: number }>) => {
state.cursorLocation = action.payload;
},
setName: (state, action: PayloadAction) => {
state.name = action.payload;
},
setOnline: (state, action: PayloadAction) => {
state.isOnline = action.payload;
},
},
});
export const { setCursorLocation, setName, setOnline } = awarenessSlice.actions;
export default awarenessSlice.reducer;
AwarenessViewModel:
class AwarenessViewModel {
private store: Store;
constructor(store: Store) {
this.store = store;
}
setCursorLocation(cursorLocation: { x: number; y: number }) {
this.store.dispatch(setCursorLocation(cursorLocation));
}
setName(name: string) {
this.store.dispatch(setName(name));
}
setOnline(isOnline: boolean) {
this.store.dispatch(setOnline(isOnline));
}
}
注意:在这个实现中,我假设 AwarenessViewModel 用于设置存储中的值。如果它也用于从存储中检索值,我们可以向 AwarenessViewModel 类添加相应的方法。
(我已从 ChatGPT 的响应中移除了样式。)
总体而言,这段代码既不坏也不完美。它不能按原样运行。它缺少 configureMockStore,并假设使用 redux-mock-store,而这不是技术栈的一部分。徐浩可以通过要求 LLM 通过一系列重写逐步调整代码来解决这些问题。这是一个移除 redux-mock-store 的例子。
当然,这是在不使用 redux-mock-store 的情况下为 AwarenessViewModel 和 awareness slice 的测试实现示例:
```typescript
import { createAwarenessSlice, AwarenessState, updateAwareness } from './awarenessSlice';
import { AwarenessViewModel } from './AwarenessViewModel';
describe('AwarenessViewModel', () => {
const mockDispatch = jest.fn();
const initialState: AwarenessState = {
users: {
'user1': {
name: 'User 1',
cursorPosition: { x: 0, y: 0 },
online: true,
},
'user2': {
name: 'User 2',
cursorPosition: { x: 100, y: 100 },
online: true,
},
},
};
let viewModel: AwarenessViewModel;
beforeEach(() => {
viewModel = new AwarenessViewModel(initialState, mockDispatch);
});
describe('getUserNames', () => {
it('returns the list of user names', () => {
const names = viewModel.getUserNames();
expect(names).toEqual(['User 1', 'User 2']);
});
});
describe('getCursorPosition', () => {
it('returns the cursor position for a given user ID', () => {
const cursorPosition = viewModel.getCursorPosition('user1');
expect(cursorPosition).toEqual({ x: 0, y: 0 });
});
it('returns undefined for an unknown user ID', () => {
const cursorPosition = viewModel.getCursorPosition('unknown');
expect(cursorPosition).toBeUndefined();
});
});
describe('updateCursorPosition', () => {
it('dispatches the updateAwareness action with the new cursor position', () => {
viewModel.updateCursorPosition('user1', { x: 50, y: 50 });
expect(mockDispatch).toHaveBeenCalledWith(updateAwareness({
user: 'user1',
cursorPosition: { x: 50, y: 50 },
}));
});
});
});
以及 awarenessSlice 的实现示例,不使用 redux-mock-store:
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export type CursorPosition = { x: number, y: number };
export type UserAwareness = {
name: string;
cursorPosition: CursorPosition;
online: boolean;
};
export type AwarenessState = {
users: Record<string, UserAwareness>;
};
const initialState: AwarenessState = {
users: {},
};
const awarenessSlice = createSlice({
name: 'awareness',
initialState,
reducers: {
updateAwareness: (state, action: PayloadAction<{ user: string, cursorPosition: CursorPosition }>) => {
const { user, cursorPosition } = action.payload;
state.users[user].cursorPosition = cursorPosition;
},
setOnline: (state, action: PayloadAction<{ user: string, online: boolean }>) => {
const { user, online } = action.payload;
state.users[user].online = online;
},
},
});
export const { updateAwareness, setOnline } = awarenessSlice.actions;
export const awarenessReducer = awarenessSlice.reducer;
注意,由于我们不使用 redux-mock-store,我们需要将 mock dispatch 函数传递给视图模型构造函数,在 updateCursorPosition 的测试中,我们需要使用 jest.fn() 检查是否派发了正确的操作。
进一步的重写可以融入更多调整,例如要求它使用 configureTestingStore('awareness', initialState) 来初始化测试存储,这样可以使其符合团队的测试习惯。然后通过一些路径调整和为 reducer 提供少量粘合代码,代码就可以正常运行。
一旦这个工作正常,徐浩可以对主计划中的其余任务重复该过程。
在这类练习中使用 ChatGPT 的一个问题是上下文窗口有限(也称为"token 限制")。一旦在对话中收到足够多的词语(或更严格地说是 token),它就开始忘记最早的内容,这种效果使其显得相当健忘。对于短聊天来说这不明显,但在使用大量上下文的情况下(如本练习所示)确实重要。ChatGPT 一直在逐步增加其上下文窗口,GPT-4 的 token 限制为 8,192,另一个变体增加到了 32,768。
徐浩发现 token 限制对他的工作有三个主要影响。首先,ChatGPT 可能停止生成内容:这很容易修复,因为我们可以用类似"you are not finished"或"go on"这样的提示来提示它。其次,如果整体提示太大,ChatGPT 就会返回一个致命错误,我们必须开始新的对话。
第三个是更棘手的问题,ChatGPT 开始忘记并丢失上下文。当这种情况发生时,我们需要重置上下文。这就是获得主计划并将任务分解为单独元素的方法的帮助之处。我们可以开始一个新的对话,带着原始策略和主计划,并要求它为计划上的不同项目生成代码。
徐浩发现上下文中的思维链对于使生成的代码相互配合至关重要,尽管代码是在不同的会话中生成的。有时他需要向提示中添加一个提示来生成内聚的代码,但他发现通过修改思维链反而获得了更好的结果。
我从这次讨论中的收获是,使用思维链和生成的知识提示方法对编程来说可以是一个显著有用的工具。特别是,它表明要很好地使用 LLM,我们需要学会如何构造提示以获得最佳结果。这种经历表明,有用的是