Building High-Performance Userspace eBPF VMs with LLVM

We are excited to introduce llvmbpf, a new project aimed at empowering developers with a high-performance, multi-architecture eBPF virtual machine (VM) that leverages the LLVM framework for Just-In-Time (JIT) and Ahead-Of-Time (AOT) compilation.

This component is part of the bpftime project but focuses solely on the core VM. It operates as a standalone eBPF VM library or a compiler tool. This library is optimized for performance, flexibility, and minimal dependencies, making it easy to integrate into various environments without unnecessary overhead.

Why llvmbpf?

Although there are several userspace eBPF runtimes available, we built llvmbpf to address specific needs that existing solutions may not fully satisfy:

  1. AOT Compiler: The ability to compile eBPF bytecode into native ELF object files allows developers to deploy pre-compiled eBPF programs, ensuring high performance and efficiency, especially in resource-constrained environments. Additionally, it can allow you to experiment with different optimization techniques based on LLVM IR, providing more flexibility and control over the compilation process.

  2. Standalone Deployment: With llvmbpf, you can build eBPF programs into standalone binaries that don’t require external dependencies. This feature is particularly useful for deploying eBPF programs on embedded systems, microcontrollers, or other environments where installing additional software is impractical. Compared to native C code development, this ensures the eBPF part is verified after integration with the verifier.

  3. All-Architecture Support: llvmbpf is designed to be compatible across multiple architectures, making it versatile for a wide range of hardware platforms.

  4. Maps and Relocation Support: Unlike many other userspace eBPF solutions, llvmbpf provides robust support for maps, data relocation, and lddw helper functions, allowing for the creation of more complex and powerful eBPF programs.

  5. Extensible Optimization Approaches: Leveraging LLVM’s powerful optimization capabilities, llvmbpf allows for advanced optimizations such as inlining maps and helper functions, as well as using original LLVM IR for enhanced performance.

In this blog, we’ll walk through some practical examples of how to use llvmbpf, highlighting its core features and capabilities.

For a comprehensive userspace eBPF runtime that includes support for maps, helpers, and seamless execution of Uprobe, syscall trace, XDP, and other eBPF programs—similar to kernel functionality but in userspace—please refer to the bpftime project.

Getting Started with llvmbpf

Using llvmbpf as a Library

llvmbpf can be used as a library within your application to load and execute eBPF programs. Here’s a basic example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void run_ebpf_prog(const void *code, size_t code_len) {
uint64_t res = 0;
llvmbpf_vm vm;

res = vm.load_code(code, code_len);
if (res) {
return;
}
vm.register_external_function(2, "print", (void *)ffi_print_func);
auto func = vm.compile();
if (!func) {
return;
}
int err = vm.exec(&bpf_mem, sizeof(bpf_mem), res);
if (err != 0) {
return;
}
printf("res = %" PRIu64 "\n", res);
}

This snippet shows how you can load eBPF bytecode, register external functions, and execute the program within the VM.

Using llvmbpf as an AOT Compiler

One of the most powerful features of llvmbpf is its ability to function as an AOT compiler, converting eBPF bytecode into native ELF object files. This approach not only boosts performance but also simplifies the deployment of eBPF programs.

You can use the CLI to generate LLVM IR from eBPF bytecode:

1
2
3
# ./build/cli/bpftime-vm build .github/assets/sum.bpf.o -emit-llvm > test.bpf.ll
# opt -O3 -S test.bpf.ll -opaque-pointers -o test.opt.ll
# cat test.opt.ll

AOT Compile an eBPF program:

1
2
3
# ./build/cli/bpftime-vm build .github/assets/sum.bpf.o
[info] Processing program test
[info] Program test written to ./test.o

Load and run an AOT-compiled eBPF program:

1
2
3
4
# echo "AwAAAAEAAAACAAAAAwAAAA==" | base64 -d > test.bin
# ./build/cli/bpftime-vm run test.o test.bin
[info] LLVM-JIT: Loading aot object
[info] Program executed successfully. Return value: 6

The resulting ELF object file can be linked with other object files or loaded directly into the llvmbpf runtime, making it highly versatile for different use cases.

Loading eBPF Bytecode from ELF Files

llvmbpf supports loading eBPF bytecode directly from ELF files, which is a common format for storing compiled eBPF programs. This feature is particularly useful when working with existing eBPF toolchains.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
bpf_object *obj = bpf_object__open(ebpf_elf.c_str());
if (!obj) {
return 1;
}
std::unique_ptr<bpf_object, decltype(&bpf_object__close)> elf(
obj, bpf_object__close);

bpf_program *prog;
for ((prog) = bpf_object__next_program((elf.get()), nullptr);
(prog) != nullptr;
(prog) = bpf_object__next_program((elf.get()), (prog))) {
llvmbpf_vm vm;
vm.load_code((const void *)bpf_program__insns(prog),
(uint32_t)bpf_program__insn_cnt(prog) * 8);
}

However, the bpf.o ELF file has no map and data relocation support. We recommend using bpftime to load and relocate the eBPF bytecode from an ELF file. This includes:

  • Writing a loader similar to the kernel eBPF loader to load the eBPF bytecode (see an example here).
  • Using libbpf, which supports:
    • Relocation for maps, where the map ID is allocated by the loader and bpftime. You can use the map ID to access maps through the helpers.
    • Accessing data through the lddw helper function.
  • After loading the eBPF bytecode and completing relocation, you can use the bpftimetool to dump the map information and eBPF bytecode.

Maps and Data Relocation Support

llvmbpf offers extensive support for maps and data relocation, allowing developers to write more complex eBPF programs that interact with different data sources. For instance, you can use helper functions to access maps or define maps as global variables in your eBPF programs.

The eBPF can work with maps in two ways:

  • Using helper functions to access the maps, like bpf_map_lookup_elem, bpf_map_update_elem, etc.
  • Using maps as global variables in the eBPF program and accessing the maps directly.
1
2
3
4
5
6
7
8
9
10
11
12
uint32_t ctl_array[2] = { 0, 0 };
uint64_t cntrs_array[2] = { 0, 0 };

void *bpf_map_lookup_elem(uint64_t map_fd, void *key) {
if (map_fd == 5) {
return &ctl_array[*(uint32_t *)key];
} else if (map_fd == 6) {
return &cntrs_array[*(uint32_t *)key];
} else {
return nullptr;
}
}

Building into Standalone Binary for Deployment

One of the standout features of llvmbpf is the ability to compile eBPF programs into standalone binaries. This makes it possible to deploy eBPF applications in environments where installing dependencies is not feasible, such as microcontrollers or other embedded systems.

You can build the eBPF program into a standalone binary that does not rely on any external libraries and can be executed like normal C code with helper and map support.

This approach offers several benefits:

  • Easily deploy the eBPF program to any machine without needing to install dependencies.
  • Avoid the overhead of loading the eBPF bytecode and maps at runtime.
  • Make it suitable for microcontrollers or embedded systems that do not have an OS.

Here’s a basic example:

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
#include <stdint.h>
#include <stdio.h>
#include <inttypes.h>

int bpf_main(void* ctx, uint64_t size);

uint32_t ctl_array[2] = { 0, 0 };
uint64_t cntrs_array[2] = { 0, 0 };

void *_bpf_helper_ext_0001(uint64_t map_fd, void *key) {
printf("bpf_map_lookup_elem %lu\n", map_fd);
if (map_fd == 5) {
return &ctl_array[*(uint32_t *)key];
} else if (map

_fd == 6) {
return &cntrs_array[*(uint32_t *)key];
} else {
return NULL;
}
}

void* __lddw_helper_map_val(uint64_t val) {
printf("map_val %lu\n", val);
if (val == 5) {
return (void *)ctl_array;
} else if (val == 6) {
return (void *)cntrs_array;
} else {
return NULL;
}
}

uint8_t bpf_mem[] = { 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88 };

int main() {
printf("The value of cntrs_array[0] is %" PRIu64 "\n", cntrs_array[0]);
printf("calling ebpf program...\n");
bpf_main(bpf_mem, sizeof(bpf_mem));
printf("The value of cntrs_array[0] is %" PRIu64 "\n", cntrs_array[0]);
return 0;
}

Compile the C code with the LLVM IR:

1
clang -g main.c xdp-counter.ll -o standalone 

You can then run the standalone eBPF program directly. Compared to native C code development, this ensures that the eBPF part is verified after integration with the verifier.

Optimization Techniques

llvmbpf provides several optimization techniques to enhance the performance of eBPF programs. Two notable methods include:

Inlining Maps and Helper Functions

By inlining maps and helper functions, llvmbpf reduces the overhead of function calls, enabling more efficient execution of eBPF programs.

1
2
3
clang -S -O3 -emit-llvm libmap.c -o libmap.ll
llvm-link -S -o xdp-counter-inline.ll xdp-counter.ll libmap.ll
opt --always-inline -S xdp-counter-inline.ll -o xdp-counter-inline.ll

Using Original LLVM IR from C Code

Instead of relying solely on eBPF instructions, llvmbpf allows developers to use original LLVM IR generated from C code. This flexibility opens the door for more advanced optimizations and higher performance.

1
2
3
4
int bpf_main(void* ctx, int size) {
_bpf_helper_ext_0006("hello world: %d\n", size);
return 0;
}

eBPF is an instruction set designed for verification, but it may not be the best for performance. llvmbpf also supports using the original LLVM IR from C code. See example/load-llvm-ir for an example. You can:

  • Compile the C code to eBPF for verification.
  • Compile the C code to LLVM IR and native code for execution in the VM.

Conclusion

llvmbpf is a powerful tool for developers looking to leverage eBPF outside the kernel. With features like AOT compilation, standalone deployment, and extensive support for maps and relocation, it offers a flexible and high-performance solution for a wide range of use cases. Whether you’re working on networking, security, or performance monitoring applications, llvmbpf provides the tools you need to build efficient and portable eBPF programs.

Simplifying Kernel Programming: The LLM-Powered eBPF Tool

Kernel programming can be intimidating, requiring deep knowledge of operating system internals and programming constraints. Our latest tool, Kgent, aims to change that by making it easier than ever to create extended Berkeley Packet Filters (eBPF) programs. Kgent leverages the power of large language models (LLMs) to translate natural language prompts into eBPF code, opening up kernel programming to a wider audience.

Our paper, “Kgent: Kernel Extensions Large Language Model Agent,” was recently presented at eBPF ‘24: Proceedings of the ACM SIGCOMM 2024 Workshop on eBPF and Kernel Extensions. Let’s dive into what makes Kgent a game-changer for kernel programming.

The Key Idea Behind Kgent

Kgent simplifies the traditionally complex process of writing eBPF programs. By translating user prompts in natural language to eBPF code, it eliminates the need for deep OS kernel knowledge. This tool combines program comprehension, symbolic execution, and feedback loops to ensure the synthesized program is accurate and aligns with the user’s intent.

Highlights

  • Natural Language to eBPF: Kgent can take user prompts in plain English and convert them into functional eBPF programs.
  • Combination of Techniques: It employs a mix of program comprehension, symbolic execution, and feedback loops to ensure high accuracy.
  • Evaluation: Our tests show that Kgent achieves a 2.67x improvement over GPT-4 in producing correct eBPF programs, with a high accuracy rate and minimal false positives.

Potential Use Cases

Kgent can be utilized in various scenarios to facilitate kernel development and management:

  1. System Administrators: Helps junior sys admins create and maintain eBPF programs without needing extensive OS kernel knowledge.
  2. DevOps Personnel: Assists in writing and deploying kernel extensions for monitoring and tracing applications, enhancing system performance and security.
  3. Patch Makers: Simplifies the creation of patches by translating natural language descriptions of issues and fixes into eBPF programs.
  4. Kernel Developers: Speeds up the prototyping and validation of kernel extensions, saving time and reducing errors.
  5. Educational Purposes: Serves as a learning aid for students and new developers to understand eBPF programming through natural language interactions.
  6. Research and Experimentation: Provides a platform for researchers to explore new eBPF applications and test hypotheses without diving into complex coding.
  7. Network Tools Development: Eases the creation of custom network monitoring, security, and performance analysis tools by translating high-level requirements into efficient eBPF programs.

Why we need kgent instead of just ask GPT?

While large language models (LLMs) like GPT-4 can suggest code, they often recommend incorrect helpers or non-existent APIs—a phenomenon known as hallucination. Given the small and limited set of helpers and kfuncs in eBPF, these issues can be fixed relatively easily. Another common issue is incorrect attach points. In eBPF, programs must attach to specific kernel events, such as kprobes, tracepoints, and perf events. Incorrect attach events can either be rejected by the kernel or, worse, pass the verifier and load incorrectly, leading to wrong results.

The eBPF verifier adds another layer of complexity. For instance, loop code often cannot pass the verifier due to safety checks. Although the verifier prevents harmful code, it cannot always prevent incorrect code. For example, when asked to write a program to trace TCP connect events, GPT-4’s generated code failed to read the port number correctly and didn’t consider IPv6.

To help the LLM learn about new knowledge like eBPF, common approaches include fine-tuning or Retrieval-Augmented Generation (RAG). However, publicly available examples of eBPF are insufficient, and eBPF abilities can change across kernel versions. RAG is a promising solution, as it allows the model to retrieve the most up-to-date and relevant information from external sources. This method combines language model generation with relevant information retrieval from a vector database.

The LLM Agent Framework

To address these issues, we built an LLM agent with three core components: planning, tools, and memory.

kgent

Plan Component
The agent follows a predefined workflow:

  1. Prompter: Retrieves related examples, attach points, and specs based on user input.
  2. Synthesis Engine: Creates eBPF candidates from the prompt.
  3. Comprehension Engine: Annotates the eBPF candidate, adding necessary assumptions and assertions for verification.
  4. Symbolic Verifier: Verifies the candidate’s behavior. If invalid, the process iterates until a valid program is produced, forming a feedback loop.
    For some cases, it can also use ReAct mode for decision-making.

kgent

Tools Component
The agent can use various tools like clang to compile eBPF programs, Seahorn for verification, and bpftrace for obtaining attach points and running eBPF programs.

Memory Component
The agent uses short-term in-context memory to remember past actions, errors, and decisions, ensuring the feedback loop is successful.

Example Workflow
Let’s take a simple bpftrace program as an example. Suppose a user requests: “Trace tcp_connect events for both IPv4 and IPv6 connection attempts, and display the source and destination IP addresses.” The agent forms a prompt based on a predefined template and asks the LLM to generate the program. We use in-context learning and few-shot techniques, including examples in the template’s context. The examples vector database contains samples from BCC, bpftrace, and our own collection. The agent searches for similar examples based on user input and includes these examples in the prompt.

We also built a pipeline to generate specifications and descriptions for each hook point and helper function from the kernel source code. For instance, when building the spec database, we generate the spec for the tcp_connect_init function in the kernel using the LLM. During the synthesis step, the agent can search for related function specs with user input in the vector database.

Limitations and Future Work

While Kgent is a significant step forward, it has some limitations. Currently, our implementation focuses on small programs under 100 lines due to the LLM’s context window limit. Additionally, our eBPF program dataset is relatively small, which restricts the tool’s ability to handle more complex and varied tasks. Right now, Kgent’s use cases are mostly limited to simple trace programs and network functions.

We are exploring ways to extend Kgent’s capabilities. For example, we know that tools like ChatGPT can handle many tasks using its Python code interpreter. This raises exciting possibilities: can we automate larger tasks like auto-monitoring and auto-performance tuning? Could an LLM help analyze results from different tools and even find these tools automatically? Could it play a role in rapidly developing solutions for urgent problems?

To tackle these challenges, we are considering splitting larger tasks into smaller, manageable parts, similar to the approach used by AutoGPT. This would allow the LLM to plan the overall structure of the program, generate each component, and then merge them together. Additionally, involving users in the iteration process could provide interactive feedback, improving the quality of the generated programs.

We also acknowledge that writing correct Hoare contracts is challenging for LLMs, and current verification methods may not cover all behaviors of the generated eBPF programs. To improve this, we need better background descriptions and more robust Hoare expressions. Incorporating more software engineering practices, such as counterexample generation and test-driven development, could help ensure comprehensive verification.

Another critical concern is security. Since eBPF runs in the kernel, any flaws could lead to significant issues. We plan to involve users more in the review process to mitigate these risks and ensure the safety of the generated programs.

Conclusion

Kgent is revolutionizing the way we approach kernel programming by making eBPF program creation accessible to a broader audience. By translating natural language into functional eBPF code, it opens up kernel extension development to system administrators, DevOps personnel, patch makers, and more. Our paper, presented at eBPF ‘24, highlights the potential of this tool to democratize kernel programming and foster innovation.

We invite you to explore Kgent and see how it can transform your approach to kernel development. For more details, check out our eBPF’24 paper and visit our GitHub repository. For additional details, refer to the earlier Arxiv version: KEN: Kernel Extensions using Natural Language. For a more usable and simplified tool, check out GPTtrace. You can also try the GPTtrace simplified web demo here.

By lowering the barrier to entry for writing eBPF programs, Kgent is promoting innovation and enhancing system capabilities, one natural language prompt at a time.

L7 Tracing with eBPF: HTTP and Beyond via Socket Filters and Syscall Tracing

In today’s technology landscape, with the rise of microservices, cloud-native applications, and complex distributed systems, observability of systems has become a crucial factor in ensuring their health, performance, and security. Especially in a microservices architecture, application components may be distributed across multiple containers and servers, making traditional monitoring methods often insufficient to provide the depth and breadth needed to fully understand the behavior of the system. This is where observing seven-layer protocols such as HTTP, gRPC, MQTT, and more becomes particularly important.

Seven-layer protocols provide detailed insights into how applications interact with other services and components. In a microservices environment, understanding these interactions is vital, as they often serve as the root causes of performance bottlenecks, failures, and security issues. However, monitoring these protocols is not a straightforward task. Traditional network monitoring tools like tcpdump, while effective at capturing network traffic, often fall short when dealing with the complexity and dynamism of seven-layer protocols.

This is where eBPF (extended Berkeley Packet Filter) technology comes into play. eBPF allows developers and operators to delve deep into the kernel layer, observing and analyzing system behavior in real-time without the need to modify or insert instrumentation into application code. This presents a unique opportunity to handle application layer traffic more simply and efficiently, particularly in microservices environments.

In this tutorial, we will delve into the following:

  • Tracking seven-layer protocols such as HTTP and the challenges associated with them.
  • eBPF’s socket filter and syscall tracing: How these two technologies assist in tracing HTTP network request data at different kernel layers, and the advantages and limitations of each.
  • eBPF practical tutorial: How to develop an eBPF program and utilize eBPF socket filter or syscall tracing to capture and analyze HTTP traffic.

As network traffic increases and applications grow in complexity, gaining a deeper understanding of seven-layer protocols becomes increasingly important. Through this tutorial, you will acquire the necessary knowledge and tools to more effectively monitor and analyze your network traffic, ultimately enhancing the performance of your applications and servers.

This article is part of the eBPF Developer Tutorial, and for more detailed content, you can visit here. The source code is available on the GitHub repository.

Challenges in Tracking HTTP, HTTP/2, and Other Seven-Layer Protocols

In the modern networking environment, seven-layer protocols extend beyond just HTTP. In fact, there are many seven-layer protocols such as HTTP/2, gRPC, MQTT, WebSocket, AMQP, and SMTP, each serving critical roles in various application scenarios. These protocols provide detailed insights into how applications interact with other services and components. However, tracking these protocols is not a simple task, especially within complex distributed systems.

  1. Diversity and Complexity: Each seven-layer protocol has its specific design and workings. For example, gRPC utilizes HTTP/2 as its transport protocol and supports multiple languages, while MQTT is a lightweight publish/subscribe messaging transport protocol designed for low-bandwidth and unreliable networks.

  2. Dynamism: Many seven-layer protocols are dynamic, meaning their behavior can change based on network conditions, application requirements, or other factors.

  3. Encryption and Security: With increased security awareness, many seven-layer protocols employ encryption technologies such as TLS/SSL. This introduces additional challenges for tracking and analysis, as decrypting traffic is required for in-depth examination.

  4. High-Performance Requirements: In high-traffic production environments, capturing and analyzing traffic for seven-layer protocols can impact system performance. Traditional network monitoring tools may struggle to handle a large number of concurrent sessions.

  5. Data Completeness and Continuity: Unlike tools like tcpdump, which capture individual packets, tracking seven-layer protocols requires capturing complete sessions, which may involve multiple packets. This necessitates tools capable of correctly reassembling and parsing these packets to provide a continuous session view.

  6. Code Intrusiveness: To gain deeper insights into the behavior of seven-layer protocols, developers may need to modify application code to add monitoring functionalities. This not only increases development and maintenance complexity but can also impact application performance.

As mentioned earlier, eBPF provides a powerful solution, allowing us to capture and analyze seven-layer protocol traffic in the kernel layer without modifying application code. This approach not only offers insights into system behavior but also ensures optimal performance and efficiency. This is why eBPF has become the preferred technology for modern observability tools, especially in production environments that demand high performance and low latency.

eBPF Socket Filter vs. Syscall Tracing: In-Depth Analysis and Comparison

eBPF Socket Filter

What Is It?
eBPF socket filter is an extension of the classic Berkeley Packet Filter (BPF) that allows for more advanced packet filtering directly within the kernel. It operates at the socket layer, enabling fine-grained control over which packets are processed by user-space applications.

Key Features:

  • Performance: By handling packets directly within the kernel, eBPF socket filters reduce the overhead of context switches between user and kernel spaces.
  • Flexibility: eBPF socket filters can be attached to any socket, providing a universal packet filtering mechanism for various protocols and socket types.
  • Programmability: Developers can write custom eBPF programs to define complex filtering logic beyond simple packet matching.

Use Cases:

  • Traffic Control: Restrict or prioritize traffic based on custom conditions.
  • Security: Discard malicious packets before they reach user-space applications.
  • Monitoring: Capture specific packets for analysis without affecting other traffic.

eBPF Syscall Tracing

What Is It?
System call tracing using eBPF allows monitoring and manipulation of system calls made by applications. System calls are the primary mechanism through which user-space applications interact with the kernel, making tracing them a valuable way to understand application behavior.

Key Features:

  • Granularity: eBPF allows tracing specific system calls, even specific parameters within those system calls.
  • Low Overhead: Compared to other tracing methods, eBPF syscall tracing is designed to have minimal performance impact.
  • Security: Kernel validates eBPF programs to ensure they do not compromise system stability.

How It Works:
eBPF syscall tracing typically involves attaching eBPF programs to tracepoints or kprobes related to the system calls being traced. When the traced system call is invoked, the eBPF program is executed, allowing data collection or even modification of system call parameters.

Comparison of eBPF Socket Filter and Syscall Tracing

Aspect eBPF Socket Filter eBPF Syscall Tracing
Operational Layer Socket layer, primarily dealing with network packets received from or sent to sockets. System call layer, monitoring and potentially altering the behavior of system calls made by applications.
Primary Use Cases Mainly used for filtering, monitoring, and manipulation of network packets. Used for performance analysis, security monitoring, and debugging of interactions with the network.
Granularity Focuses on individual network packets. Can monitor a wide range of system activities, including those unrelated to networking.
Tracking HTTP Traffic Can be used to filter and capture HTTP packets passed through sockets. Can trace system calls associated with networking operations, which may include HTTP traffic.

In summary, both eBPF socket filters and syscall tracing can be used to trace HTTP traffic, but socket filters are more direct and suitable for this purpose. However, if you are interested in the broader context of how an application interacts with the system (e.g., which system calls lead to HTTP traffic), syscall tracing can be highly valuable. In many advanced observability setups, both tools may be used simultaneously to provide a comprehensive view of system and network behavior.

Capturing HTTP Traffic with eBPF Socket Filter

eBPF code consists of user-space and kernel-space components, and here we primarily focus on the kernel-space code. Below is the main logic for capturing HTTP traffic in the kernel using eBPF socket filter technology, and the complete code is provided:

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
SEC("socket")
int socket_handler(struct __sk_buff *skb)
{
struct so_event *e;
__u8 verlen;
__u16 proto;
__u32 nhoff = ETH_HLEN;
__u32 ip_proto = 0;
__u32 tcp_hdr_len = 0;
__u16 tlen;
__u32 payload_offset = 0;
__u32 payload_length = 0;
__u8 hdr_len;

bpf_skb_load_bytes(skb, 12, &proto, 2);
proto = __bpf_ntohs(proto);
if (proto != ETH_P_IP)
return 0;

if (ip_is_fragment(skb, nhoff))
return 0;

// ip4 header lengths are variable
// access ihl as a u8 (linux/include/linux/skbuff.h)
bpf_skb_load_bytes(skb, ETH_HLEN, &hdr_len, sizeof(hdr_len));
hdr_len &= 0x0f;
hdr_len *= 4;

/* verify hlen meets minimum size requirements */
if (hdr_len < sizeof(struct iphdr))
{
return 0;
}

bpf_skb_load_bytes(skb, nhoff + offsetof(struct iphdr, protocol), &ip_proto, 1);

if (ip_proto != IPPROTO_TCP)
{
return 0;
}

tcp_hdr_len = nhoff + hdr_len;
bpf_skb_load_bytes(skb, nhoff + 0, &verlen, 1);
bpf_skb_load_bytes(skb, nhoff + offsetof(struct iphdr, tot_len), &tlen, sizeof(tlen));

__u8 doff;
bpf_skb_load_bytes(skb, tcp_hdr_len + offsetof(struct __tcphdr, ack_seq) + 4, &doff, sizeof(doff)); // read the first byte past __tcphdr->ack_seq, we can't do offsetof bit fields
doff &= 0xf0; // clean-up res1
doff >>= 4; // move the upper 4 bits to low
doff *= 4; // convert to bytes length

payload_offset = ETH_HLEN + hdr_len + doff;
payload_length = __bpf_ntohs(tlen) - hdr_len - doff;

char line_buffer[7];
if (payload_length < 7 || payload_offset < 0)
{
return 0;
}
bpf_skb_load_bytes(skb, payload_offset, line_buffer, 7);
bpf_printk("%d len %d buffer: %s", payload_offset, payload_length, line_buffer);
if (bpf_strncmp(line_buffer, 3, "GET") != 0 &&
bpf_strncmp(line_buffer, 4, "POST") != 0 &&
bpf_strncmp(line_buffer, 3, "PUT") != 0 &&
bpf_strncmp(line_buffer, 6, "DELETE") != 0 &&
bpf_strncmp(line_buffer, 4, "HTTP") != 0)
{
return 0;
}

/* reserve sample from BPF ringbuf */
e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
if (!e)
return 0;

e->ip_proto = ip_proto;
bpf_skb_load_bytes(skb, nhoff + hdr_len, &(e->ports), 4);
e->pkt_type = skb->pkt_type;
e->ifindex = skb->ifindex;

e->payload_length = payload_length;
bpf_skb_load_bytes(skb, payload_offset, e->payload, MAX_BUF_SIZE);

bpf_skb_load_bytes(skb, nhoff + offsetof(struct iphdr, saddr), &(e->src_addr), 4);
bpf_skb_load_bytes(skb, nhoff + offsetof(struct iphdr, daddr), &(e->dst_addr), 4);
bpf_ringbuf_submit(e, 0);

return skb->len;
}

When analyzing this eBPF program, we will explain it in detail according to the content of each code block and provide relevant background knowledge:

1
2
3
4
5
SEC("socket")
int socket_handler(struct __sk_buff *skb)
{
// ...
}

This is the entry point of the eBPF program, defining a function named socket_handler that the kernel uses to handle incoming network packets. This function is located in an eBPF section named socket, indicating that it is intended for socket handling.

1
2
3
4
5
6
7
8
9
10
struct so_event *e;
__u8 verlen;
__u16 proto;
__u32 nhoff = ETH_HLEN;
__u32 ip_proto = 0;
__u32 tcp_hdr_len = 0;
__u16 tlen;
__u32 payload_offset = 0;
__u32 payload_length = 0;
__u8 hdr_len;

In this code block, several variables are defined to store information needed during packet processing. These variables include struct so_event *e for storing event information, verlen, proto, nhoff, ip_proto, tcp_hdr_len, tlen, payload_offset, payload_length, and hdr_len for storing packet information.

  • struct so_event *e;: This is a pointer to the so_event structure for storing captured event information. The specific definition of this structure is located elsewhere in the program.
  • __u8 verlen;, __u16 proto;, __u32 nhoff = ETH_HLEN;: These variables are used to store various pieces of information, such as protocol types, packet offsets, etc. nhoff is initialized to the length of the Ethernet frame header, typically 14 bytes, as Ethernet frame headers include destination MAC address, source MAC address, and frame type fields.
  • __u32 ip_proto = 0;: This variable is used to store the type of the IP protocol and is initialized to 0.
  • __u32 tcp_hdr_len = 0;: This variable is used to store the length of the TCP header and is initialized to 0.
  • __u16 tlen;: This variable is used to store the total length of the IP packet.
  • __u32 payload_offset = 0;, __u32 payload_length = 0;: These two variables are used to store the offset and length of the HTTP request payload.
  • __u8 hdr_len;: This variable is used to store the length of the IP header.
1
2
3
4
bpf_skb_load_bytes(skb, 12, &proto, 2);
proto = __bpf_ntohs(proto);
if (proto != ETH_P_IP)
return 0;

Here, the code loads the Ethernet frame type field from the packet, which tells us the network layer protocol being used in the packet. It then uses the __bpf_ntohs function to convert the network byte order type field into host byte order. Next, the code checks if the type field is not equal to the Ethernet frame type for IPv4 (0x0800). If it’s not equal, it means the packet is not an IPv4 packet, and the function returns 0, indicating that the packet should not be processed.

Key concepts to understand here:

  • Ethernet Frame: The Ethernet frame is a data link layer (Layer 2) protocol used for transmitting data frames within a local area network (LAN). Ethernet frames typically include destination MAC address, source MAC address, and frame type fields.
  • Network Byte Order: Network protocols often use big-endian byte order to represent data. Therefore, data received from the network needs to be converted into host byte order for proper interpretation on the host. Here, the type field from the network is converted to host byte order for further processing.
  • IPv4 Frame Type (ETH_P_IP): This represents the frame type field in the Ethernet frame, where 0x0800 indicates IPv4.
1
2
if (ip_is_fragment(skb, nhoff))
return 0;

This part of the code checks if IP fragmentation is being handled. IP fragmentation is a mechanism for splitting larger IP packets into multiple smaller fragments for transmission. Here, if the packet is an IP fragment, the function returns 0, indicating that only complete packets will be processed.

1
2
3
4
5
6
7
8
static inline int ip_is_fragment(struct __sk_buff *skb, __u32 nhoff)
{
__u16 frag_off;

bpf_skb_load_bytes(skb, nhoff + offsetof(struct iphdr, frag_off), &frag_off, 2);
frag_off = __bpf_ntohs(frag_off);
return frag_off & (IP_MF | IP_OFFSET);
}

The above code is a helper function used to check if the incoming IPv4 packet is an IP fragment. IP fragmentation is a mechanism where, if the size of an IP packet exceeds the Maximum Transmission Unit (MTU) of the network, routers split it into smaller fragments for transmission across the network. The purpose of this function is to examine the fragment flags and fragment offset fields within the packet to determine if it is a fragment.

Here’s an explanation of the code line by line:

  1. __u16 frag_off;: Defines a 16-bit unsigned integer variable frag_off to store the fragment offset field.
  2. bpf_skb_load_bytes(skb, nhoff + offsetof(struct iphdr, frag_off), &frag_off, 2);: This line of code uses the bpf_skb_load_bytes function to load the fragment offset field from the packet. nhoff is the offset of the IP header within the packet, and offsetof(struct iphdr, frag_off) calculates the offset of the fragment offset field within the IPv4 header.
  3. frag_off = __bpf_ntohs(frag_off);: Converts the loaded fragment offset field from network byte order (big-endian) to host byte order. Network protocols typically use big-endian to represent data, and the conversion to host byte order is done for further processing.
  4. return frag_off & (IP_MF | IP_OFFSET);: This line of code checks the value of the fragment offset field using a bitwise AND operation with two flag values:
    • IP_MF: Represents the “More Fragments” flag. If this flag is set to 1, it indicates that the packet is part of a fragmented sequence and more fragments are expected.
    • IP_OFFSET: Represents the fragment offset field. If the fragment offset field is non-zero, it indicates that the packet is part of a fragmented sequence and has a fragment offset value.
      If either of these flags is set to 1, the result is non-zero, indicating that the packet is an IP fragment. If both flags are 0, it means the packet is not fragmented.

It’s important to note that the fragment offset field in the IP header is specified in units of 8 bytes, so the actual byte offset is obtained by left-shifting the value by 3 bits. Additionally, the “More Fragments” flag (IP_MF) in the IP header indicates whether there are more fragments in the sequence and is typically used in conjunction with the fragment offset field to indicate the status of fragmented packets.

1
2
3
4
5
bpf_skb_load_bytes(skb, ETH_HLEN, &

hdr_len, sizeof(hdr_len));
hdr_len &= 0x0f;
hdr_len *= 4;

In this part of the code, the length of the IP header is loaded from the packet. The IP header length field contains information about the length of the IP header in units of 4 bytes, and it needs to be converted to bytes. Here, it is converted by performing a bitwise AND operation with 0x0f and then multiplying it by 4.

Key concept:

  • IP Header: The IP header contains fundamental information about a packet, such as the source IP address, destination IP address, protocol type, total length, identification, flags, fragment offset, time to live (TTL), checksum, source port, and destination port.
1
2
3
4
if (hdr_len < sizeof(struct iphdr))
{
return 0;
}

This code segment checks if the length of the IP header meets the minimum length requirement, typically 20 bytes. If the length of the IP header is less than 20 bytes, it indicates an incomplete or corrupted packet, and the function returns 0, indicating that the packet should not be processed.

Key concept:

  • struct iphdr: This is a structure defined in the Linux kernel, representing the format of an IPv4 header. It includes fields such as version, header length, service type, total length, identification, flags, fragment offset, time to live, protocol, header checksum, source IP address, and destination IP address, among others.
1
2
3
4
5
bpf_skb_load_bytes(skb, nhoff + offsetof(struct iphdr, protocol), &ip_proto, 1);
if (ip_proto != IPPROTO_TCP)
{
return 0;
}

Here, the code loads the protocol field from the IP header to determine the transport layer protocol used in the packet. Then, it checks if the protocol field is not equal to the value for TCP (IPPROTO_TCP). If it’s not TCP, it means the packet is not an HTTP request or response, and the function returns 0.

Key concept:

  • Transport Layer Protocol: The protocol field in the IP header indicates the transport layer protocol used in the packet, such as TCP, UDP, or ICMP.
1
tcp_hdr_len = nhoff + hdr_len;

This line of code calculates the offset of the TCP header. It adds the length of the Ethernet frame header (nhoff) to the length of the IP header (hdr_len) to obtain the starting position of the TCP header.

1
bpf_skb_load_bytes(skb, nhoff + 0, &verlen, 1);

This line of code loads the first byte of the TCP header from the packet, which contains information about the TCP header length. This length field is specified in units of 4 bytes and requires further conversion.

1
bpf_skb_load_bytes(skb, nhoff + offsetof(struct iphdr, tot_len), &tlen, sizeof(tlen));

This line of code loads the total length field of the IP header from the packet. The IP header’s total length field represents the overall length of the IP packet, including both the IP header and the data portion.

1
2
3
4
5
__u8 doff;
bpf_skb_load_bytes(skb, tcp_hdr_len + offsetof(struct __tcphdr, ack_seq) + 4, &doff, sizeof(doff));
doff &= 0xf0;
doff >>= 4;
doff *= 4;

This piece of code is used to calculate the length of the TCP header. It loads the Data Offset field (also known as the Header Length field) from the TCP header, which represents the length of the TCP header in units of 4 bytes. The code clears the high four bits of the offset field, then shifts it right by 4 bits, and finally multiplies it by 4 to obtain the actual length of the TCP header.

Key points to understand:

  • TCP Header: The TCP header contains information related to the TCP protocol, such as source port, destination port, sequence number, acknowledgment number, flags (e.g., SYN, ACK, FIN), window size, and checksum.
1
2
payload_offset = ETH_HLEN + hdr_len + doff;
payload_length = __bpf_ntohs(tlen) - hdr_len - doff;

These two lines of code calculate the offset and length of the HTTP request payload. They add the lengths of the Ethernet frame header, IP header, and TCP header together to obtain the offset to the data portion of the HTTP request. Then, by subtracting the total length, IP header length, and TCP header length from the total length field, they calculate the length of the HTTP request data.

Key point:

  • HTTP Request Payload: The actual data portion included in an HTTP request, typically consisting of the HTTP request headers and request body.
1
2
3
4
5
6
7
char line_buffer[7];
if (payload_length < 7 || payload_offset < 0)
{
return 0;
}
bpf_skb_load_bytes(skb, payload_offset, line_buffer, 7);
bpf_printk("%d len %d buffer: %s", payload_offset, payload_length, line_buffer);

This portion of the code loads the first 7 bytes of the HTTP request line and stores them in a character array named line_buffer. It then checks if the length of the HTTP request data is less than 7 bytes or if the offset is negative. If these conditions are met, it indicates an incomplete HTTP request, and the function returns 0. Finally, it uses the bpf_printk function to print the content of the HTTP request line to the kernel log for debugging and analysis.

1
2
3
4
5
6
7
8
if (bpf_strncmp(line_buffer, 3, "GET") != 0 &&
bpf_strncmp(line_buffer, 4, "POST") != 0 &&
bpf_strncmp(line_buffer, 3, "PUT") != 0 &&
bpf_strncmp(line_buffer, 6, "DELETE") != 0 &&
bpf_strncmp(line_buffer, 4, "HTTP") != 0)
{
return 0;
}

This piece of code uses the bpf_strncmp function to compare the data in line_buffer with HTTP request methods (GET, POST, PUT, DELETE, HTTP). If there is no match, indicating that it is not an HTTP request, it returns 0, indicating that it should not be processed.

1
2
3
e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
if (!e)
return 0;

This section of the code attempts to reserve a block of memory from the BPF ring buffer to store event information. If it cannot reserve the memory block, it returns 0. The BPF ring buffer is used to pass event data between the eBPF program and user space.

Key point:

  • BPF Ring Buffer: The BPF ring buffer is a mechanism for passing data between eBPF programs and user space. It can be used to store event information for further processing or analysis by user space applications.
1
2
3
4
5
6
7
8
9
10
11
12
13
e->ip_proto = ip_proto;
bpf_skb_load_bytes(skb, nhoff + hdr_len, &(e->ports), 4);
e->pkt_type = skb->pkt_type;
e->ifindex = skb->ifindex;

e->payload_length = payload_length;
bpf_skb_load_bytes(skb, payload_offset, e->payload, MAX_BUF_SIZE);

bpf_skb_load_bytes(skb, nhoff + offsetof(struct iphdr, saddr), &(e->src_addr), 4);
bpf_skb_load_bytes(skb, nhoff + offsetof(struct iphdr, daddr), &(e->dst_addr), 4);
bpf_ringbuf_submit(e, 0);

return skb->len;

Finally, this code segment stores the captured event information in the e structure and submits it to the BPF ring buffer. It includes information such as the captured IP protocol, source and destination ports, packet type, interface index, payload length, source IP address, and destination IP address. Finally, it returns the length of the packet, indicating that the packet was successfully processed.

This code is primarily used to store captured event information for further processing. The BPF ring buffer is used to pass this information to user space for additional handling or logging.

In summary, this eBPF program’s main task is to capture HTTP requests. It accomplishes this by parsing the Ethernet frame, IP header, and TCP header of incoming packets to determine if they contain HTTP requests. Information about the requests is then stored in the so_event structure and submitted to the BPF ring buffer. This is an efficient method for capturing HTTP traffic at the kernel level and is suitable for applications such as network monitoring and security analysis.

Potential Limitations

The above code has some potential limitations, and one of the main limitations is that it cannot handle URLs that span multiple packets.

  • Cross-Packet URLs: The code checks the URL in an HTTP request by parsing a single data packet. If the URL of an HTTP request spans multiple packets, it will only examine the URL in the first packet. This can lead to missing or partially capturing long URLs that span multiple data packets.

To address this issue, a solution often involves reassembling multiple packets to reconstruct the complete HTTP request. This may require implementing packet caching and assembly logic within the eBPF program and waiting to collect all relevant packets until the HTTP request is detected. This adds complexity and may require additional memory to handle cases where URLs span multiple packets.

User-Space Code

The user-space code’s main purpose is to create a raw socket and then attach the previously defined eBPF program in the kernel to that socket, allowing the eBPF program to capture and process network packets received on that socket. Here’s an example of the user-space code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* Create raw socket for localhost interface */
sock = open_raw_sock(interface);
if (sock < 0) {
err = -2;
fprintf(stderr, "Failed to open raw socket\n");
goto cleanup;
}

/* Attach BPF program to raw socket */
prog_fd = bpf_program__fd(skel->progs.socket_handler);
if (setsockopt(sock, SOL_SOCKET, SO_ATTACH_BPF, &prog_fd, sizeof(prog_fd))) {
err = -3;
fprintf(stderr, "Failed to attach to raw socket\n");
goto cleanup;
}
  1. sock = open_raw_sock(interface);: This line of code calls a custom function open_raw_sock, which is used to create a raw socket. Raw sockets allow a user-space application to handle network packets directly without going through the protocol stack. The interface parameter might specify the network interface from which to receive packets, determining where to capture packets from. If creating the socket fails, it returns a negative value, otherwise, it returns the file descriptor of the socket sock.
  2. If the value of sock is less than 0, indicating a failure to open the raw socket, it sets err to -2 and prints an error message on the standard error stream.
  3. prog_fd = bpf_program__fd(skel->progs.socket_handler);: This line of code retrieves the file descriptor of the socket filter program (socket_handler) previously defined in the eBPF program. It is necessary to attach this program to the socket. skel is a pointer to an eBPF program object, and it provides access to the program collection.
  4. setsockopt(sock, SOL_SOCKET, SO_ATTACH_BPF, &prog_fd, sizeof(prog_fd)): This line of code uses the setsockopt system call to attach the eBPF program to the raw socket. It sets the SO_ATTACH_BPF option and passes the file descriptor of the eBPF program to the option, letting the kernel know which eBPF program to apply to this socket. If the attachment is successful, the socket starts capturing and processing network packets received on it.
  5. If setsockopt fails, it sets err to -3 and prints an error message on the standard error stream.

Compilation and Execution

The complete source code can be found at https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/23-http. To compile and run the code:

1
2
3
4
5
6
7
8
$ git submodule update --init --recursive
$ make
BPF .output/sockfilter.bpf.o
GEN-SKEL .output/sockfilter.skel.h
CC .output/sockfilter.o
BINARY sockfilter
$ sudo ./sockfilter
...

In another terminal, start a simple web server using Python:

1
2
3
python3 -m http.server
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
127.0.0.1 - - [18/Sep/2023 01:05:52] "GET / HTTP/1.1" 200 -

You can use curl to make requests:

1
2
3
4
5
6
7
$ curl http://0.0.0.0:8000/
<!DOCTYPE HTML>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Directory listing for /</title>
....

In the eBPF program, you can see that it prints the content of HTTP requests:

1
2
3
4
5
6
7
8
9
127.0.0.1:34552(src) -> 127.0.0.1:8000(dst)
payload: GET / HTTP/1.1
Host: 0.0.0.0:8000
User-Agent: curl/7.88.1
...
127.0.0.1:8000(src) -> 127.0.0.1:34552(dst)
payload: HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.11.4
...

It captures both request and response content.

Capturing HTTP Traffic Using eBPF Syscall Tracepoints

eBPF provides a powerful mechanism for tracing system calls at the kernel level. In this example, we’ll use eBPF to trace the accept and read system calls to capture HTTP traffic. Due to space limitations, we’ll provide a brief overview of the code framework.

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
struct
{
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 4096);
__type(key, u64);
__type(value, struct accept_args_t);
} active_accept_args_map SEC(".maps");

// Define a tracepoint at the entry of the accept system call
SEC("tracepoint/syscalls/sys_enter_accept")
int sys_enter_accept(struct trace_event_raw_sys_enter *ctx)
{
u64 id = bpf_get_current_pid_tgid();
// ... Get and store the arguments of the accept call
bpf_map_update_elem(&active_accept_args_map, &id, &accept_args, BPF_ANY);
return 0;
}

// Define a tracepoint at the exit of the accept system call
SEC("tracepoint/syscalls/sys_exit_accept")
int sys_exit_accept(struct trace_event_raw_sys_exit *ctx)
{
// ... Process the result of the accept call
struct accept_args_t *args =
bpf_map_lookup_elem(&active_accept_args_map, &id);
// ... Get and store the socket file descriptor obtained from the accept call
__u64 pid_fd = ((__u64)pid << 32) | (u32)ret_fd;
bpf_map_update_elem(&conn_info_map, &pid_fd, &conn_info, BPF_ANY);
// ...
}

struct
{
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 4096);
__type(key, u64);
__type(value, struct data_args_t);
} active_read_args_map SEC(".maps");

// Define a tracepoint at the entry of the read system call
SEC("tracepoint/syscalls/sys_enter_read")
int sys_enter_read(struct trace_event_raw_sys_enter *ctx)
{
// ... Get and store the arguments of the read call
bpf_map_update_elem(&active_read_args_map, &id, &read_args, BPF_ANY);
return 0;
}

// Helper function to check if it's an HTTP connection
static inline bool is_http_connection(const char *line_buffer, u64 bytes_count)
{
// ... Check if the data is an HTTP request or response
}

// Helper function to process the read data
static inline void process_data(struct trace_event_raw_sys_exit *ctx,
u64 id, const struct data_args_t *args, u64 bytes_count)
{
// ... Process the read data, check if it's HTTP traffic, and send events
if (is_http_connection(line_buffer, bytes_count))
{
// ...
bpf_probe_read_kernel(&event.msg, read_size, args->buf);
// ...
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU,
&event, sizeof(struct socket_data_event_t));
}
}

// Define a tracepoint at the exit of the read system call
SEC("tracepoint/syscalls/sys_exit_read")
int sys_exit_read(struct trace_event_raw_sys_exit *ctx)
{
// ... Process the result of the read call
struct data_args_t *read_args = bpf_map_lookup_elem(&active_read_args_map, &id);
if (read_args != NULL)
{
process_data(ctx, id, read_args, bytes_count);
}
// ...
return 0;
}

char _license[] SEC("license") = "GPL";

This code briefly demonstrates how to use eBPF to trace system calls in the Linux kernel to capture HTTP traffic. Here’s a detailed explanation of the hook locations and the flow, as well as the complete set of system calls that need to be hooked for comprehensive request tracing:

Hook Locations and Flow

  • The code uses eBPF Tracepoint functionality. Specifically, it defines a series of eBPF programs and binds them to specific system call Tracepoints to capture entry and exit events of these system calls.

  • First, it defines two eBPF hash maps (active_accept_args_map and active_read_args_map) to store system call parameters. These maps are used to track accept and read system calls.

  • Next, it defines multiple Tracepoint tracing programs, including:

    • sys_enter_accept: Defined at the entry of the accept system call, used to capture the arguments of the accept system call and store them in the hash map.
    • sys_exit_accept: Defined at the exit of the accept system call, used to process the result of the accept system call, including obtaining and storing the new socket file descriptor and related connection information.
    • sys_enter_read: Defined at the entry of the read system call, used to capture the arguments of the read system call and store them in the hash map.
    • sys_exit_read: Defined at the exit of the read system call, used to process the result of the read system call, including checking if the read data is HTTP traffic and sending events.
  • In sys_exit_accept and sys_exit_read, there is also some data processing and event sending logic, such as checking if the data is an HTTP connection, assembling event data, and using bpf_perf_event_output to send events to user space for further processing.

Complete Set of System Calls to Hook

To fully implement HTTP request tracing, the system calls that typically need to be hooked include:

  • socket: Used to capture socket creation for tracking new connections.
  • bind: Used to obtain port information where the socket is bound.
  • listen: Used to start listening for connection requests.
  • accept: Used to accept connection requests and obtain new socket file descriptors.
  • read: Used to capture received data and check if it contains HTTP requests.
  • write: Used to capture sent data and check if it contains HTTP responses.

The provided code already covers the tracing of accept and read system calls. To complete HTTP request tracing, additional system calls need to be hooked, and corresponding logic needs to be implemented to handle the parameters and results of these system calls.

The complete source code can be found at https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/23-http.

Summary

In today’s complex technological landscape, system observability has become crucial, especially in the context of microservices and cloud-native applications. This article explores how to leverage eBPF technology for tracing the seven-layer protocols, along with the challenges and solutions that may arise in this process. Here’s a summary of the content covered in this article:

  1. Introduction:

    • Modern applications often consist of multiple microservices and distributed components, making it essential to observe the behavior of the entire system.
    • Seven-layer protocols (such as HTTP, gRPC, MQTT, etc.) provide detailed insights into application interactions, but monitoring these protocols can be challenging.
  2. Role of eBPF Technology:

    • eBPF allows developers to dive deep into the kernel layer for real-time observation and analysis of system behavior without modifying or inserting application code.
    • eBPF technology offers a powerful tool for monitoring seven-layer protocols, especially in a microservices environment.
  3. Tracing Seven-Layer Protocols:

    • The article discusses the challenges of tracing seven-layer protocols, including their complexity and dynamism.
    • Traditional network monitoring tools struggle with the complexity of seven-layer protocols.
  4. Applications of eBPF:

    • eBPF provides two primary methods for tracing seven-layer protocols: socket filters and syscall tracing.
    • Both of these methods help capture network request data for protocols like HTTP and analyze them.
  5. eBPF Practical Tutorial:

    • The article provides a practical eBPF tutorial demonstrating how to capture and analyze HTTP traffic using eBPF socket filters or syscall tracing.
    • The tutorial covers the development of eBPF programs, the use of the eBPF toolchain, and the implementation of HTTP request tracing.

Through this article, readers can gain a deep understanding of how to use eBPF technology for tracing seven-layer protocols, particularly HTTP traffic. This knowledge will help enhance the monitoring and analysis of network traffic, thereby improving application performance and security. If you’re interested in learning more about eBPF and its practical applications, you can visit our tutorial code repository at https://github.com/eunomia-bpf/bpf-developer-tutorial or our website at https://eunomia.dev/tutorials/ for more examples and complete tutorials.

OpenAI 新发布GPT 最佳实践:落地大模型应用的策略和战术

在今年六月份,OpenAI 在其官方文档中更新了一篇关于提高 GPT 效果的策略和方法。这篇文章包含了六种核心策略,以及一些实际的提示词案例,和知识检索和代码执行等技术来优化GPT模型的最佳实践。通过使用这些最佳实践,用户可以更好地使用 GPT 模型,并提高其效果和性能。

大部分的示例主要针对 GPT-4 模型,但对于其他模型而言也会有不少参考价值。

本文主要翻译和整理自 OpenAI 的官方文档,原文地址:https://platform.openai.com/docs/guides/gpt-best-practices

一些相关的开源资料仓库:

文末附有更多相关参考资料。

提高结果的六种策略

编写清晰的指令

GPT 无法读取您的思想。如果它们的输出过长,请要求简洁回复。如果它们的输出过于简单,请要求专业水平的写作。如果您不喜欢某种格式,请展示您想要看到的格式。GPT 越少猜测您想要的内容,您获得的可能性就越大。

策略:

  • 在查询中包含详细信息,以获得更相关的答案。
  • 要求模型扮演某个角色。
  • 使用分隔符清晰地表示输入的不同部分。
  • 指定完成任务所需的步骤。
  • 提供示例。
  • 指定输出的期望长度。
  • 提供参考文本。

提供参考文本

GPT 可以自信地编造假答案,特别是当被询问奇特的话题、引用和网址时。就像一张笔记可以帮助学生在考试中取得更好的成绩一样,为 GPT 提供参考文本可以帮助它以较少的虚构进行回答。

策略:

  • 指示模型使用参考文本进行回答。
  • 指示模型使用参考文本中的引用进行回答。

将复杂任务分解为简单子任务

就像在软件工程中将复杂系统分解为一组模块化组件一样,提交给 GPT 的任务也是如此。相比较而言,复杂任务的错误率往往较高。此外,复杂任务通常可以重新定义为一系列较简单任务的工作流程,其中早期任务的输出用于构建后续任务的输入。

策略:

  • 使用意图分类来识别用户查询的最相关指令。
  • 对于需要非常长对话的对话应用程序,总结或过滤以前的对话。
  • 逐段概括长文档并递归构建完整概要。

给予 GPT 足够的时间进行“思考”

如果被要求计算 17 乘以 28,您可能无法立即知道答案,但可以通过时间来计算出来。同样,GPT 在试图立即回答问题时会出现更多的推理错误,而不是花时间思考答案。在得出答案之前,要求进行一连串的推理过程可以帮助 GPT 更可靠地推理出正确答案。

策略:

  • 指示模型在得出结论之前自行解决问题。
  • 使用内心独白或一系列查询来隐藏模型的推理过程。
  • 询问模型是否在之前的处理中漏掉了任何内容。

使用外部工具

通过向 GPT 提供其他工具的输出来弥补 GPT 的不足之处。例如,文本检索系统可以向 GPT 提供相关文档信息。代码执行引擎可以帮助 GPT 进行数学计算和代码运行。如果通过工具而不是 GPT 可以更可靠或更高效地完成任务,则将其卸载以获得最佳结果。

策略:

  • 使用基于嵌入的搜索来实现高效的知识检索。
  • 使用代码执行来执行更准确的计算或调用外部 API。

系统地测试变更

如果您能够进行衡量,那么改进性能就会更容易。在某些情况下,对提示的修改可能会在一些孤立的示例上实现更好的性能,但在更具代表性的一组示例上导致更差的综合性能。因此,为了确保变更对性能有正面的影响,可能需要定义一个全面的测试套件(也称为“评估”)。

策略:

  • 使用参考标准答案评估模型输出。

具体的示例

每个策略都可以通过具体的战术进行实施。这些战术旨在提供尝试的思路。它们并不是完全详尽的,您可以随意尝试不在此处列出的创造性想法。本文为每个具体的策略与战术提供了一些提示词示例。

策略:编写清晰的指令

战术:在查询中包含细节以获得更相关的回答

为了获得高度相关的回复,请确保请求提供任何重要的细节或上下文。否则,您将让模型猜测您的意思。

更差的指令 更好的指令
如何在Excel中添加数字? 如何在Excel中累加一行美元金额?我想要自动为整个工作表的行求和,所有总数都显示在右侧的名为”Total”的列中。
谁是总统? 2021年墨西哥的总统是谁?选举多久举行一次?
编写计算斐波那契数列的代码。 编写一个高效计算斐波那契数列的TypeScript函数。详细注释代码,解释每个部分的作用以及为什么这样编写。
总结会议记录。 用一段话总结会议记录。然后,使用Markdown列表列出发言者及其主要观点。最后,列出发言者建议的下一步行动或待办事项(如果有)。

战术:要求模型扮演角色

系统消息可以用于指定模型在回复中扮演的角色。

1
2
3
4
5
6
USER
写一封感谢信给我的螺栓供应商,感谢他们准时并在短时间内交货。这使我们能够交付一份重要的订单。

SYSTEM
当我请求帮助写东西时,你将在每个段落中至少加入一个笑话或俏皮话。

战术:使用分隔符清晰标示输入的不同部分

像三重引号、XML标记、节标题等分隔符可以帮助标示需要以不同方式处理的文本部分。

1
2
3
4
5
USER
使用三重引号中的文本撰写一首俳句。

"""在这里插入文本"""

1
2
3
4
5
6
7
8
SYSTEM
你将获得一对关于同一主题的文章(用XML标记分隔)。首先总结每篇文章的论点。然后指出哪篇文章提出了更好的论点,并解释为什么。

USER
<article>在这里插入第一篇文章</article>

<article>在这里插入第二篇文章</article>

1
2
3
4
5
6
7
8
9
10
SYSTEM
你将获得一份论文摘要和一个建议的标题。论文标题应该给读者一个很好的论文主题概念,同时也要引人注目。如果标题不符合这些标准,请

提出5个备选方案。

USER
摘要:在这里插入摘要

标题:在这里插入标题

对于像这样的简单任务,使用分隔符可能不会对输出质量产生影响。然而,任务越复杂,将任务细节澄清变得越重要。不要让 GPTs 努力理解您究竟在要求什么。

战术:指定完成任务所需的步骤

某些任务最好指定为一系列步骤。明确写出这些步骤可以让模型更容易跟随。

1
2
3
4
5
6
7
SYSTEM
使用以下逐步说明来回应用户输入。

步骤1 - 用户将用三重引号提供给您文本。用前缀"Summary: "对这段文本进行一句总结。

步骤2 - 将步骤1中的总结翻译成西班牙语,并使用前缀"Translation: "。

战术:提供示例

通常情况下,提供适用于所有示例的一般说明比通过示例演示任务的所有变体更高效,但在某些情况下,提供示例可能更容易。例如,如果您打算让模型复制一种难以明确描述的用户查询响应风格。这被称为”few-shot”提示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
SYSTEM
用一致的风格回答。

USER
教我什么是耐心。

ASSISTANT
刻出最深峡谷的河流起源于一处平凡的泉眼;最壮丽的交响乐源于一颗单独的音符;最复杂的挂毯始于一根孤立的线缕。

USER
教我关于海洋。

Open in Playground

战术:指定所需的输出长度

您可以要求模型生成指定长度的输出。目标输出长度可以根据词数、句子数、段落数、项目符号数等来指定。但请注意,指示模型生成特定数量的单词并不具有高精确度。模型更可靠地生成具有特定段落或项目符号数量的输出。

1
2
3
4
5
USER
用大约50个词总结由三重引号分隔的文本。

"""在这里插入文本"""

1
2
3
4
5
USER
用2个段落总结由三重引号分隔的文本。

"""在这里插入文本"""

1
2
3
4
5
USER
用3个项目符号总结由三重引号分隔的文本。

"""在这里插入文本"""

策略:提供参考文本

战术:指示模型使用参考文本回答问题

如果我们能够为模型提供与当前查询相关的可信信息,那么我们可以指示模型使用提供的信息来组成其答案。

1
2
3
4
5
6
7
SYSTEM
使用由三重引号分隔的所提供的文章来回答问题。如果答案在文章中找不到,写下"I could not find an answer."
USER
<插入文章,每篇文章由三重引号分隔>

问题:<插入问题>

鉴于GPT具有有限的上下文窗口,为了应用此策略,我们需要某种方式动态查找与被提问的问题相关的信息。可以使用嵌入来实现高效的知识检索。查看策略”使用基于嵌入的搜索实现高效知识检索”了解更多关于如何实现这一点的细节。

战术:指示模型使用参考文本的引文进行回答

如果输入已经被相关知识补充,直接要求模型通过引用所提供文档的段落来添加引文到其回答中就很简单了。请注意,可以通过在所提供的文档中进行字符串匹配来编程验证输出中的引文。

1
2
3
4
5
6
7
SYSTEM
你将得到一个由三重引号分隔的文档和一个问题。你的任务是只使用提供的文档来回答问题,并引用用来回答问题的文档段落。如果文档不包含回答此问题所需的信息,那么只需写下:“信息不足”。如果提供了问题的答案,必须用引文进行注释。使用以下格式引用相关段落 ({"citation": …})。
USER
"""<插入文档>"""

<插入问题>

策略:将复杂任务分解为更简单的子任务

战术:使用意图分类来识别用户查询最相关的指令

对于需要大量独立的指令集来处理不同情况的任务,首先分类查询类型并使用分类来确定需要哪些指令可能是有益的。这可以通过定义固定的类别并硬编码与处理给定类别任务相关的指令来实现。此过程也可以递归应用以将任务分解为一系列阶段。这种方法的优点是每个查询只包含执行任务的下一阶段所需的那些指令,这可能导致比使用单个查询执行整个任务时的错误率更低。这也可能导致成本更低,因为更大的提示运行成本更高(参见价格信息)。

假设例如,对于客户服务应用,查询可能被有用地分类如下:

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
SYSTEM
你将得到客户服务查询。将每个查询分类为主要类别和次要类别。以json格式提供你的输出,包含主要和次要两个关键字。

主要类别:计费,技术支持,账户管理,或一般咨询。

计费次要类别:
- 退订或升级
- 添加付款方式
- 收费解释
- 争议收费

技术支持次要类别:
- 故障排除
- 设备兼容性
- 软件更新

账户管理次要类别:
- 密码重置
- 更新个人信息
- 关闭账户
- 账户安全

一般咨询次要类别:
- 产品信息
- 价格
- 反馈
- 要求与人对话
USER
我需要让我的互联网再次工作。

基于客户查询的分类,可以向GPT模型提供一组更具体的指令来处理下一步。例如,假设客户需要帮助”故障排除”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SYSTEM
你将得到需要在技术支持环境中进行故障排除的客户服务查询。通过以下方式帮助用户:

- 让他们检查到/从路由器的所有电缆是否已连接。注意,电缆随着时间的推移会常常松动。
- 如果所有电缆都已连接并且问题仍然存在,询问他们正在使用哪种路由器模型
- 现在你将建议他们如何重新启动他们的设备:
-- 如果型号是MTD-327J,建议他们按下红色按钮并保持5秒钟,然后等待5分钟后再测试连接。
-- 如果型号是MTD-327S,建议他们拔掉并重新插入,然后等待5分钟后再测试连接。
- 如果客户在重启设备并等待5分钟后的问题仍然存在,通过输出{"IT support requested"}将他们连接到IT支持。
- 如果用户开始提问与此主题无关的问题,那么请确认他们是否希望结束当前关于故障排除的聊天,并根据以下方案对他们的请求进行分类:

<插入上述主/次分类方案>
USER
我需要让我的互联网再次工作。

请注意,已经指示模型在会话状态改变时发出特殊的字符串。这使我们能够将我们的系统转变为状态机,其中状态决定哪些指令被注入。通过跟踪状态,什么指令在那个状态下是相关的,以及从那个状态允许什么状态转换,我们可以在用户体验周围设置保护,这在一种不太结构化的方法中很难实现。

战术

:对需要进行非常长对话的对话应用程序,对先前的对话进行汇总或过滤 由于GPT具有固定的上下文长度,用户和助手之间的对话不能无限地继续,如果整个对话都包含在上下文窗口中。

解决这个问题的方法有很多,其中之一是对对话的前几个回合进行汇总。一旦输入的大小达到预定的阈值长度,这可能会触发一个查询,该查询会汇总对话的一部分,先前对话的汇总可以作为系统消息的一部分。或者,先前的对话可以在整个对话过程中异步地进行汇总。

另一个解决方案是动态选择与当前查询最相关的对话的先前部分。参见战术”使用基于嵌入的搜索来实现高效的知识检索”。

战术:分段汇总长文档并递归构造完整汇总

由于GPT具有固定的上下文长度,它们不能在单个查询中用来汇总超过上下文长度减去生成汇总长度的文本。

要汇总一个非常长的文档,如一本书,我们可以使用一系列的查询来汇总每一部分的文档。部分汇总可以被连接起来并进行汇总,产生汇总的汇总。这个过程可以递归地进行,直到整个文档被汇总。如果需要使用关于早期部分的信息来理解后来的部分,那么在对那一点的内容进行汇总时包含一个运行汇总的文本,这可能是一个有用的技巧。OpenAI在之前的研究中已经研究了这种用于汇总书籍的程序的有效性,使用的是GPT-3的变体。

策略:给予 GPT 时间“思考”

战术:在匆忙得出结论前,指导模型自行找出解决方案

有时候,我们明确地指导模型在得出结论之前先进行从头至尾的推理,会得到更好的结果。比如,假设我们希望模型评估一个学生解决数学问题的方法。最直观的做法就是直接询问模型学生的解决方案是否正确。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
SYSTEM
判断学生的解答是否正确。

USER
问题描述:我正在建造一个太阳能发电装置,我需要帮助计算财务状况。
- 土地费用为每平方英尺100美元
- 我可以以每平方英尺250美元的价格购买太阳能板
- 我已经商定了一个每年固定费用为10万美元的维护合同,以及每平方英尺额外的10美元
第一年运营的总成本是多少,作为面积(平方英尺)的函数来表示。

学生的解答:设x为装置的面积(平方英尺)。
1. 土地费用:100x
2. 太阳能板费用:250x
3. 维护费用:100,000 + 100x
总成本:100x + 250x + 100,000 + 100x = 450x + 100,000

ASSISTANT
学生的解答是正确的。

但实际上,学生的解答并不正确!我们可以通过提示模型首先生成自己的解答,从而让模型成功地注意到这一点。

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
SYSTEM
首先自己解决这个问题,然后将你的解答与学生的解答进行比较,评估学生的解答是否正确。在你自己解决这个问题之前,不要判断学生的解答是否正确。

USER
问题描述:我正在建造一个太阳能发电装置,我需要帮助计算财务状况。
- 土地费用为每平方英尺100美元
- 我可以以每平方英尺250美元的价格购买太阳能板
- 我已经商定了一个每年固定费用为10万美元的维护合同,以及每平方英尺额外的10美元
第一年运营的总成本是多少,作为面积(平方英尺)的函数来表示。

学生的解答:设x为装置的面积(平方英尺)。
1. 土地费用:100x
2. 太阳能板费用:250x
3. 维护费用:100,000 + 100x
总成本:100x

+ 250x + 100,000 + 100x = 450x + 100,000

ASSISTANT
设x为装置的面积(平方英尺)。
1. 土地费用:100x
2. 太阳能板费用:250x
3. 维护费用:100,000 + 10x
总成本:100x + 250x + 100,000 + 10x = 360x + 100,000

学生的解答是不正确的。他们在计算维护费用时犯了错误,使用了100x而不是10x。第一年运营的正确总成本应为360x + 100,000。

战术:使用内心独白或一系列查询来隐藏模型的推理过程

前一个战术表明,模型有时需要在回答特定问题之前详细地推理一个问题。对于某些应用程序,模型用来得出最终答案的推理过程是不适合与用户分享的。例如,在辅导应用程序中,我们可能希望鼓励学生自己找出答案,但是模型对学生解答的推理过程可能会向学生透露答案。

内心独白是一种可以用来解决这个问题的战术。内心独白的想法是,指导模型将那些应该对用户隐藏的输出部分放入一种结构化的格式中,使其易于解析。然后,在向用户呈现输出之前,解析输出并只使部分输出可见。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SYSTEM
按照以下步骤回答用户的查询。

步骤 1 - 首先自己解决问题。不要依赖学生的解答,因为它可能是错误的。将此步骤的所有工作都包含在三引号(""")中。

步骤 2 - 将你的解答与学生的解答进行比较,并评估学生的解答是否正确。将此步骤的所有工作都包含在三引号(""")中。

步骤 3 - 如果学生犯了错误,确定你可以给学生什么提示,而不透露答案。将此步骤的所有工作都包含在三引号(""")中。

步骤 4 - 如果学生犯了错误,将上一步的提示提供给学生(在三引号之外)。写"提示:",而不是"步骤4 - ...”。

USER
问题描述: <插入问题描述>

学生的解答: <插入学生的解答>

或者,这也可以通过一系列查询实现,其中所有查询的输出(除了最后一个)都对最终用户隐藏。

首先,我们可以让模型自己解决问题。

由于这个初始查询不需要学生的解答,所以可以省略它。这提供了额外的优势,即模型的解答不可能被学生尝试的解答偏倚。

1
2
3
USER
<插入问题描述>

接下来,我们可以让模型使用所有可用的信息来评估学生解答的正确性。

1
2
3
4
5
6
7
8
9
10
SYSTEM
将你的解答与学生的解答进行比较,然后评估学生的解答是否正确。

USER
问题描述:"""<插入问题描述>"""

你的解答:"""<插入模型生成的解答>"""

学生的解答:"""<插入学生的解答>"""

最后,我们可以让模型使用自己的分析,构造一个有帮助的导师的回复。

1
2
3
4
5
6
7
8
9
10
11
12
SYSTEM
你是一位数学导师。如果学生犯了错误,以不透露答案的方式给学生提供一个提示。如果学生没有犯错误,简单地对他们提供鼓励性的评论。

USER
问题描述:"""<插入问题描述>"""

你的解答:"""<插入模型生成的解答>"""

学生的解答:"""<插入学生的解答>"""

分析:"""<插入模型在前一步生成的分析>"""

战术:询问模型是否在前几轮查询中遗漏了任何内容

假设我们正在使用模型列出与特定问题相关的源文本的摘录。在列出每一篇摘录之后,模型需要确定是否应开始编写另一篇,或者是否应该停止。如果源文档很大,模型通常会过早地停止,无法列出所有相关的摘录。在这种情况下,通过提示模型用后续查询找出它在前几轮中遗漏的摘录,通常可以获得更好的性能。

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
SYSTEM
你将获得一个用三引号分隔的文档。你的任务是选择与以下问题相关的摘录:"人工智能历史上发生过哪些重大的范式转变。"

确保摘录包含所有相关的上下文,以便解释它们 - 换句话说,不要提取缺少重要上下文的小片段。以如下的JSON格式提供输出:

[{"摘录": "..."},
...
{"摘录": "..."}]

USER
"""<插入文档>"""

ASSISTANT
[{"摘录": "模型在这里写下一段摘录"},
...
{"摘录": "模型在这里写下另一段摘录"}]

USER
还有其他相关的摘录吗?请注意不要重复摘录。并确保

新的摘录确实与问题有关,而且在旧的摘录中还没有涵盖。

ASSISTANT
[{"摘录": "模型在这里写下一段摘录"},
...
{"摘录": "模型在这里写下另一段摘录"}]

注意,如果文档特别长,这个战术可能需要多次应用。

策略:使用外部工具

战术:利用基于嵌入的搜索实现高效的知识检索

模型可以利用作为其输入的外部信息源。这可以帮助模型生成更有依据和最新的响应。例如,如果用户询问关于特定电影的问题,将有关该电影的高质量信息(如演员、导演等)添加到模型的输入可能是有用的。嵌入可以用来实现高效的知识检索,以便在运行时动态地将相关信息添加到模型输入。

文本嵌入是一种可以测量文本字符串之间相关性的向量。相似或相关的字符串将比无关的字符串更接近。这个事实,再加上快速向量搜索算法的存在,意味着嵌入可以被用来实现高效的知识检索。具体来说,文本语料库可以被切割成块,每个块可以被嵌入并存储。然后,给定的查询可以被嵌入,向量搜索可以被执行,以找到与查询最相关的文本块(即,在嵌入空间中最接近的)。

实施示例可以在OpenAI Cookbook中找到。请参阅战术”Instruct the model to use retrieved knowledge to answer queries”,以获取如何使用知识检索来最小化模型制造错误事实的可能性的例子。

战术:使用代码执行进行更精确的计算或调用外部API

我们不能依赖GPT自己精确地进行算术或长时间的计算。在需要的情况下,可以指导模型编写和运行代码,而不是自己进行计算。特别是,可以指导模型将要运行的代码放入特定格式,如三重反引号。在产生输出后,可以提取并运行代码。最后,如果必要,可以将代码执行引擎(如Python解释器)的输出作为模型下一次查询的输入。

1
2
3
4
5
SYSTEM
你可以通过将代码包含在三重反引号中来编写和执行Python代码,例如 ```代码在此```。使用这种方式来进行计算。
USER
找出以下多项式的所有实数根:3*x**5 - 5*x**4 - 3*x**3 - 7*x - 10。

代码执行的另一个好用途是调用外部API。如果模型在API的正确使用上得到了指导,它就可以编写使用这个API的代码。可以通过向模型提供文档和/或代码示例来指导模型如何使用API。

1
2
3
4
5
6
7
8
9
SYSTEM
你可以通过将代码包含在三重反引号中来编写和执行Python代码。另外注意,你可以使用以下模块帮助用户向朋友发送消息:

```python
import

message
message.write(to="John", message="Hey, want to meetup after work?")

警告:执行由模型产生的代码本质上并不安全,任何希望执行此操作的应用都应该采取预防措施。特别地,需要一个沙箱化的代码执行环境来限制不受信任的代码可能导致的危害。

策略:系统地测试改变

有时候,很难确定一个改变——例如,新的指令或新的设计——是否使你的系统更好或更差。观察几个例子可能会暗示哪个更好,但是在小样本的情况下,很难区分真正的改进和随机运气。可能这种变化在某些输入上提高了性能,但在其他输入上却降低了性能。

评估程序(或“评估”)对优化系统设计很有用。良好的评估具有以下特性:

  • 代表现实世界的使用情况(或至少多样化)
  • 包含许多测试用例,以获得更大的统计能力(见下表作为指南)
  • 易于自动化或重复
检测到的差异 95%置信度所需的样本大小
30% ~10
10% ~100
3% ~1,000
1% ~10,000

输出的评估可以由计算机、人或两者混合完成。计算机可以使用目标标准(例如,具有单一正确答案的问题)以及某些主观或模糊的标准自动化评估,其中模型输出由其他模型查询进行评估。OpenAI Evals 是一个开源软件框架,提供用于创建自动化评估的工具。

当存在一系列被认为是同等高质量的可能输出(例如,对于具有长答案的问题)时,基于模型的评估可能有用。哪些可以用基于模型的评估真实地进行评估,哪些需要人来评估的边界是模糊的,随着模型变得越来越有能力,这个边界正在不断地移动。我们鼓励进行实验,以确定基于模型的评估对你的用例有多大的效果。

战术:参照标准答案评估模型输出

假设已知一个问题的正确答案应该参考一组特定的已知事实。然后,我们可以使用模型查询来计算答案中包含了多少必需的事实。

例如,使用以下的系统消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
SYSTEM
您将获得由三个引号界定的文本,这应该是问题的答案。检查以下的信息是否直接包含在答案中:

- 尼尔·阿姆斯特朗是第一个登上月球的人。
- 尼尔·阿姆斯特朗第一次走上月球的日期是1969年7月21日。

对于这些点,请执行以下步

骤:

1 - 重述这一点。
2 - 提供一个来自答案的引用,这个引用最接近这一点。
3 - 考虑一个不了解这个主题的人读了引用是否可以直接推断出这一点。在做决定之前,解释为什么或为什么不。
4 - 如果3的答案是肯定的,写“是”,否则写“否”。

最后,提供一个“是”的答案的数量。将这个数量作为{"count": <insert count here>}提供。

下面是一个例子,其中两个要点都得到了满足:

1
2
3
4
5
SYSTEM
<insert system message above>
USER
"""尼尔·阿姆斯特朗因为是第一个踏上月球的人而闻名。这个历史性的事件发生在1969年7月21日,是阿波罗11号任务的一部分。"""

这是一个只满足一个要点的输入示例:

1
2
3
4
5
SYSTEM
<insert system message above>
USER
"""尼尔·阿姆斯特朗在他从月球模块走下来时创造了历史,成为第一个在月球上行走的人。"""

这是一个没有满足任何要点的输入示例:

1
2
3
4
5
6
7
8
SYSTEM
<insert system message above>
USER
"""在'69年的夏天,一个宏大的旅程,
阿波罗11号,像传说的手一样大胆。
阿姆斯特朗迈出了一步,历史展开,
他说的'一个小步',是为了一个新的世界。"""

这种类型的基于模型的评估有许多可能的变体。考虑下面这个跟踪候选答案和金标准答案之间的重叠种类,以及候选答案是否与金标准答案的任何部分矛盾的变体。

1
2
3
4
5
6
7
8
9
SYSTEM
按照以下步骤进行。

步骤1:逐步推理提交的答案与专家答案比较,是:不相交、子集、超集,还是有相等的信息集。

步骤2:逐步推理提交的答案是否与专家答案的任何部分矛盾。

步骤3:输出一个JSON对象,结构如下:{"containment": "disjoint" or "subset" or "superset" or "equal", "contradiction": True or False}

这是一个输入例子,其中的答案质量较差:

1
2
3
4
5
6
7
8
9
10
11
12
SYSTEM
<insert system message above>
USER
Question: """尼尔·阿姆斯特朗最著名的事件是什么,它发生在什么时候?假设UTC时间。"""

Submitted Answer: """他在月球上

走了一圈吗?"""

Expert Answer: """尼尔·阿姆斯特朗最为人所知的是他是第一个踏上月球的人。这一历史性的事件发生在1969年7月21日,是NASA的阿波罗11号任务的一部分。阿姆斯特朗踏上月球表面时说的名言:"那是人类的一小步,却是人类的一大步",至今仍被广泛引用。
"""

这是一个有好答案的输入示例:

1
2
3
4
5
6
7
8
9
SYSTEM
<insert system message above>
USER
Question: """尼尔·阿姆斯特朗最著名的事件是什么,它发生在什么时候?假设UTC时间。"""

Submitted Answer: """在1969年7月21日的大约02:56 UTC时间,尼尔·阿姆斯特朗成为第一个踏上月球表面的人,标志着人类历史上的一项伟大成就。大约20分钟后,奥尔德林也加入到他的行列。"""

Expert Answer: """尼尔·阿姆斯特朗最为人所知的是他是第一个踏上月球的人。这一历史性的事件发生在1969年7月21日,是阿波罗11号任务的一部分。"""

相关资料

  • OpenAI 官方 Blog:使用 OpenAI API 进行提示词工程的最佳实践:关于如何构建一个 AI 应用,从提示词工程到向量数据库、微调等等的详细指南。

https://help.openai.com/en/articles/6654000-best-practices-for-prompt-engineering-with-openai-api

这篇文章主要针对于 GPT-3,可能相对而言有些过时,但也是一个入门的不错选择。

  • 微软发布的关于构建 AI 应用的概念和学习资料:关于如何构建一个 AI 应用,从提示词工程到向量数据库、微调等等的详细指南。

https://learn.microsoft.com/en-us/azure/cognitive-services/openai/concepts/advanced-prompt-engineering

这里也有一些相关的开源资料仓库:

guidance: 自然语言的编程语言

guidance: 自然语言的编程语言

TLDR:

微软最近发布了一个名为 guidance 的指导语言,用于控制 LLMs 的行为。该语言具有高度的灵活性和可定制性,提供了一种方便且可靠的方法来管理LLMs的相关工作。Guidance 解决了以下的问题:

  • 确保生成正确的 YAML 或者 JSON 格式,或者其他任意的格式,同时节约 token 费用
  • 相比 langchain 的 Python 代码,用更简单的 DSL,实现多步输出更为复杂和精确的结果

“Guidance”和”LangChain”都是为了帮助用户更有效地利用大型语言模型(Large Language Models, LLMs)而设计的,他们在某些功能性的方面有些类似,但是具体的实现思路、使用体验有很大的不同,Guidance 有点类似于“自然语言编程”的一种表现形式,把精确的 DSL 和模糊的大模型结果结合起来,获取更好的综合表现。

下面是关于这两个项目的一些分析:

Guidance

“Guidance”是一个用于控制大型语言模型的指导语言。它的主要目标是使用户能够更有效、更高效地控制现代语言模型,而不是通过传统的提示或链式控制【5†source】【6†source】。

它的主要功能包括:

  • 提供简单直观的语法,基于Handlebars模板
  • 支持多种生成、选择、条件、工具使用等丰富的输出结构
  • 支持在Jupyter/VSCode Notebooks中像playground一样进行流式处理
  • 提供智能种子生成缓存
  • 支持基于角色的聊天模型(例如,ChatGPT)
  • 与Hugging Face模型的易于集成,包括指导加速、优化提示边界的令牌治疗,以及使用正则表达式模式指南来强制格式【7†source】。

Guidance 的用例包含:

  1. 丰富的输出结构: guidance 允许在执行过程中交错生成和提示,使得输出结构更加精确,同时也可以生成清晰和可解析的结果。例如,它可以用于识别给定句子是否包含了时代错误(因为时间周期不重叠而不可能的陈述)。使用**guidance,可以通过一个简单的两步提示实现这个任务,其中包含了一个人工制作的思维链条序列1**。
  2. 保证有效的语法: guidance 可以保证语言模型生成的输出遵循特定的格式,这对于将语言模型的输出用作其他系统的输入非常重要。例如,如果我们想用语言模型生成一个 JSON 对象,我们需要确保输出是有效的 JSON。使用**guidance,我们可以同时加速推理速度并确保生成的 JSON 总是有效的。以下是一个使用guidance生成具有完美语法的游戏角色配置文件的示例1**。
  3. 基于角色的聊天模型: guidance 支持通过角色标签自动映射到当前 LLM 的正确令牌或 API 调用的现代聊天式模型,如 ChatGPT 和 Alpaca。README 中提供了是一个展示如何使用基于角色的指导程序实现简单的多步推理和计划的示例**1**。

LangChain

“LangChain”是一个软件开发框架,旨在简化使用大型语言模型(LLMs)创建应用程序的过程。它的用例与语言模型的用例大致相同,包括文档分析和总结、聊天机器人、代码分析等。

“LangChain”的主要功能包括:

📃 LLM和提示:

这包括提示管理、提示优化、所有LLM的通用界面以及用于处理LLM的常用工具。

🔗 链:

链超越了单个LLM调用,涉及到调用序列(无论是调用LLM还是不同的工具)。LangChain提供了链的标准接口、与其他工具的大量集成以及常见应用的端到端链。

📚 数据增强生成:

数据增强生成涉及到特定类型的链,首先与外部数据源进行交互,以获取用于生成步骤的数据。例如,长文本摘要和对特定数据源的问题/回答。

🤖 代理:

代理涉及LLM做出决策,选择行动,看到观察结果,并重复该过程直到完成。LangChain为代理提供了标准接口、一组可供选择的代理以及端到端代理的示例。

🧠 记忆:

记忆是指在链/代理的调用之间保持状态。LangChain为记忆提供了标准接口、一组记忆实现以及使用记忆的链/代理示例。

🧐 评估:

[BETA]生成模型以传统指标难以评估。一种新的评估方法是使用语言模型本身进行评估。LangChain提供了一些提示/链来协助进行此项工作。

Guidance与LangChain的比较

“Guidance”和”LangChain”都是为了帮助用户更好地使用和控制大型语言模型。两者的主要区别在于它们的关注点和使用场景。

“Guidance”主要关注于如何更有效地控制语言模型的生成过程,提供了一种更自然的方式来组织生成、提示和逻辑控制的流程。这主要适用于需要在一个连续的流程中交替使用生成、提示和逻辑控制的场景,例如,基于聊天的应用或者需要生成有特定结构的文本的应用。

“LangChain”则是一个更全面的框架,它提供了一套完整的工具和接口,用于开发和部署基于大型语言模型的应用。它包括了从数据获取、处理,到模型调用,再到结果呈现的一整套流程。所以,如果你想要开发一个完整的基于语言模型的应用,”LangChain”可能是一个更好的选择。

所以,这两个项目的相关性在于它们都是服务于大型语言模型的,但是它们的侧重点和应用场景是不同的。具体使用哪一个,主要取决于你的具体需求和使用场景。

Guidance example JSON

生成精确的 JSON 结果:

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
# we use LLaMA here, but any GPT-style model will do
llama = guidance.llms.Transformers("your_path/llama-7b", device=0)

# we can pre-define valid option sets
valid_weapons = ["sword", "axe", "mace", "spear", "bow", "crossbow"]

# define the prompt
character_maker = guidance("""The following is a character profile for an RPG game in JSON format.
```json
{
"id": "{{id}}",
"description": "{{description}}",
"name": "{{gen 'name'}}",
"age": {{gen 'age' pattern='[0-9]+' stop=','}},
"armor": "{{#select 'armor'}}leather{{or}}chainmail{{or}}plate{{/select}}",
"weapon": "{{select 'weapon' options=valid_weapons}}",
"class": "{{gen 'class'}}",
"mantra": "{{gen 'mantra' temperature=0.7}}",
"strength": {{gen 'strength' pattern='[0-9]+' stop=','}},
"items": [{{#geneach 'items' num_iterations=5 join=', '}}"{{gen 'this' temperature=0.7}}"{{/geneach}}]
}```""")

# generate a character
character_maker(
id="e1f491f7-7ab8-4dac-8c20-c92b5e7d883d",
description="A quick and nimble fighter.",
valid_weapons=valid_weapons, llm=llama
)
  • 能保证 JSON 不会出错
  • 能节约大量的 token 费用,生成时间和价格大约都只有原先直接生成 YAML 的一半

使用 LLaMA 2B 时,上述提示通常需要 5.6000 秒多一点即可在 A7 GPU 上完成。如果我们要运行适合为单次调用的相同提示(今天的标准做法),则需要大约 5 秒才能完成(其中 4 秒是令牌生成,1 秒是提示处理)。这意味着指导加速比此提示的标准方法提高了 2 倍。实际上,确切的加速系数取决于特定提示的格式和模型的大小(模型越大,受益越大)。目前也仅支持 transformer LLM的加速。

注意,这种格式控制不仅对于 jSON 有效,对于任意的其他语言或者格式,例如 YAML 等都是有效的,对于开发复杂应用或者生成 DSL 来说,会有很大的帮助。

一个更复杂的例子,同时也包含使用 {{#select}}...{{or}}...{{/select}} 命令进行控制流的选择:

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
import guidance

# set the default language model used to execute guidance programs
guidance.llm = guidance.llms.OpenAI("text-davinci-003")

# define the few shot examples
examples = [
{'input': 'I wrote about shakespeare',
'entities': [{'entity': 'I', 'time': 'present'}, {'entity': 'Shakespeare', 'time': '16th century'}],
'reasoning': 'I can write about Shakespeare because he lived in the past with respect to me.',
'answer': 'No'},
{'input': 'Shakespeare wrote about me',
'entities': [{'entity': 'Shakespeare', 'time': '16th century'}, {'entity': 'I', 'time': 'present'}],
'reasoning': 'Shakespeare cannot have written about me, because he died before I was born',
'answer': 'Yes'}
]

# define the guidance program
structure_program = guidance(
'''Given a sentence tell me whether it contains an anachronism (i.e. whether it could have happened or not based on the time periods associated with the entities).
----

{{~! display the few-shot examples ~}}
{{~#each examples}}
Sentence: {{this.input}}
Entities and dates:{{#each this.entities}}
{{this.entity}}: {{this.time}}{{/each}}
Reasoning: {{this.reasoning}}
Anachronism: {{this.answer}}
---
{{~/each}}

{{~! place the real question at the end }}
Sentence: {{input}}
Entities and dates:
{{gen "entities"}}
Reasoning:{{gen "reasoning"}}
Anachronism:{{#select "answer"}} Yes{{or}} No{{/select}}''')

# execute the program
out = structure_program(
examples=examples,
input='The T-rex bit my dog'
)

这段代码的主要目标是定义和执行一个使用 guidance 的程序,该程序处理一个指定问题:给出一个句子,告诉我这个句子是否包含了一个时间错误(即基于与实体相关联的时间周期,这件事是否可能发生)。

首先,通过 import guidance 语句导入 guidance 库。

然后,设定了默认使用的大型语言模型(LLM)guidance.llm = guidance.llms.OpenAI("text-davinci-003")。在这种情况下,使用的是 OpenAI 的 “text-davinci-003” 模型。

定义了一组“少量示例”(few-shot examples),这些示例展示了模型如何处理该问题。每个示例都包含一个句子(input),句子中涉及的实体及其时间信息(entities),推理(reasoning)以及是否存在时间错误的答案(answer)。

之后,定义了一个 guidance 程序(structure_program)。这个程序首先展示了少量示例,然后处理一个实际的问题。引导程序使用 Handlebars 模板语法来编写。例如,使用 {{#each examples}}{{~/each}} 可以遍历所有示例。此外,还使用了 {{gen}} 命令来生成文本,并使用 {{#select}}{{/select}} 命令来做出选择。

最后,执行这个程序。作为输入,提供了少量示例(examples)和一个实际问题(input)。执行的结果(out)是一个执行程序对象,可以进一步处理或分析。

整体上,这个例子展示了如何使用 guidance 库来处理一个特定问题。这个库使得对大型语言模型的控制更为高效和有效,不仅可以生成文本,还可以做出逻辑决策。

Guidance 的原理

**guidance是一个用于控制大型语言模型(LLMs,例如 GPT-3 或 GPT-4)的库。它的设计初衷是使语言模型的控制更为高效和有效。这是通过编写引导程序(guidance programs)实现的,这些程序允许你将文本生成、提示以及逻辑控制交织在一起,形成一个与语言模型处理文本的方式相匹配的连续流程1**。

引导程序基于Handlebars模板语言的简单、直观语法,但具有一些独特的功能。它们有一个与语言模型处理令牌顺序直接对应的独特线性执行顺序。这意味着在执行过程中的任何时刻,都可以使用语言模型来生成文本(使用**{{gen}}命令)或进行逻辑控制流决策(使用{{#select}}...{{or}}...{{/select}}命令)。生成和提示的交织可以使输出结构更精确,从而提高准确性,同时也产生清晰、可解析的结果1**。

guidance通过一个令牌备份模型,然后允许模型向前移动,同时限制它仅生成前缀与最后一个令牌匹配的令牌,从而消除这些偏差。这种“令牌修复”过程消除了令牌边界偏差,并允许自然地完成任何提示。

参考资料

https://github.com/microsoft/guidance

Inline Hook for various arch

实现 Inline Hook 的方法是可行的,但是这在现代操作系统中可能会遇到一些问题,因为它们通常会阻止你修改执行代码。在某些情况下,你可能需要禁用某些内存保护机制,例如数据执行防止(DEP)或地址空间布局随机化(ASLR)。另外,这种技术在处理现代的编译器优化时可能会有困难,因为它们可能会将函数内联,或者以其他方式修改函数的结构。下面是实现 Inline Hook 的基本步骤:

  1. 找到目标函数的地址:首先,你需要找到你想要 Hook 的函数在内存中的地址。你可以使用上面的 get_function_addr_elf_no_pieget_function_addr_elf_pie 函数来获取这个地址。

  2. 备份原始指令:由于你要修改目标函数的开始部分来插入跳转指令,你需要首先备份原始的指令,以便在你的 Hook 函数执行完毕后,可以跳回并执行这些被覆盖的指令。

  3. 写入跳转指令:然后,你需要在目标函数的开始部分写入一个跳转指令,这个指令将程序的执行流引导到你的 Hook 函数。

  4. 创建你的 Hook 函数:你的 Hook 函数将替代目标函数的开始部分。它应该首先执行你想要插入的代码,然后执行备份的原始指令,最后跳回到目标函数的剩余部分。

  5. 修改内存权限:在默认情况下,你的程序的代码段是只读的,这是为了防止程序意外或恶意地修改自己的代码。因此,你需要使用 mprotect 函数来修改目标函数的内存页的权限,使其成为可写的。

  6. 恢复内存权限:在修改了目标函数之后,你应该再次使用 mprotect 函数来恢复内存页的原始权限。

请注意,这种技术可能违反一些操作系统或硬件的保护机制,因此它可能不会在所有系统或配置上都能正常工作。在使用这种技术时,你应当格外小心,确保你完全理解你的修改可能带来的后果。

build and run

for x86

Below is an example of how you can modify your code to perform an inline hook for the my_function. This is a simplistic approach and works specifically for this case. This is just an illustrative example. For real-world scenarios, a more complex method would need to be employed, considering thread-safety, re-entrant code, and more.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void inline_hook(void *orig_func, void *hook_func) {
// Store the original bytes of the function.
unsigned char orig_bytes[5];
memcpy(orig_bytes, orig_func, 5);

// Make the memory page writable.
mprotect(get_page_addr(orig_func), getpagesize(), PROT_READ | PROT_WRITE | PROT_EXEC);

// Write a jump instruction at the start of the original function.
*((unsigned char *)orig_func + 0) = 0xE9; // JMP instruction
*((void **)((unsigned char *)orig_func + 1)) = (unsigned char *)hook_func - (unsigned char *)orig_func - 5;

// Make the memory page executable only.
mprotect(get_page_addr(orig_func), getpagesize(), PROT_READ | PROT_EXEC);
}

In this example, my_function is the original function that is hooked. my_hook_function is the function that gets called instead of my_function. The inline_hook function performs the actual hook by overwriting the start of my_function with a jump (JMP) instruction to my_hook_function.

When you now call my_function in your main, my_hook_function is called instead.

Please note that this code is simplified and makes a few assumptions:

  • The functions my_function and my_hook_function are in the same memory page. If they aren’t, the jump offset from my_function to my_hook_function might not fit in the 4 bytes available in the jump instruction.
  • The first 5 bytes of my_function can be safely overwritten. If there’s a multi-byte instruction that starts within the first 5 bytes but doesn’t end before the 6th byte, this will crash.
  • The functions my_function and my_hook_function don’t move in memory. If they do (for example, if they’re in a shared library that gets unloaded and reloaded at a different address), the jump instruction will jump to the wrong place and likely crash.
1
2
3
4
5
$ make
$ ./maps
Hello, world!
Hello from hook!
Hello, world!

for arm32

Note that in ARM32, the Program Counter (PC) is usually 2 instructions ahead, which is why we subtract 8 (2 instructions * 4 bytes/instruction) when calculating the offset. This might differ between different ARM versions or modes (Thumb vs ARM, etc.) so please adjust accordingly to your target’s specifics.

Also, you need to increase the SIZE_ORIG_BYTES from 16 to 20 because the minimal branch instruction in ARM is 4 bytes and you’re going to replace 5 instructions. This is needed because the branch instruction uses a relative offset and you cannot be sure how far your hook function will be. If your function and hook are within 32MB of each other, you could only replace the first 4 bytes with a branch and wouldn’t need to touch the rest.

Remember that manipulating code at runtime can be error-prone and architecture-specific. The code can behave differently based on where it’s loaded in memory, how the compiler has optimized it, whether it’s running in Thumb or ARM mode, and so on. Always thoroughly test the code in the exact conditions where it will be used.

1
2
3
4
5
$ make arm
$ ./maps-arm32
Hello, world!
Hello from hook!
Hello, world!

for arm64

Similar to ARM32, ARM64 uses the ARM instruction set. However, there are differences and specifics to consider for ARM64. For example, the encoding of the branch instruction is different and because of the larger address space, you have to create a trampoline for larger offsets that can’t be reached by a single branch instruction. The trampoline should be close to the original function so it can be reached by a branch instruction and from there, it will load the full 64 bit address of the hook function.

1
2
3
4
5
$ make arm64
$ ./maps-arm64
Hello, world!
Hello from hook!
Hello, world!

自然语言编程: 从 AutoGPT 往前迈的一小步

这段时间看到了许多使用 AI 编写代码的故事或是示例,但也许自然语言编程并不需要是像之前想象那样,使用 AI 生成代码,并且自动执行它;全自动生成软件成品是一个科幻元素,但使用 AI 去生成代码有时可能也是个伪需求。也许我们要重新审视在 AI 时代下的编程、程序,乃至于软件工程意味着什么。

另一方面,如果我们能给 AI 明确的、复杂的多步任务和指导,让它遵照执行,也可能能极大地提升 AI 的逻辑分析能力与规划能力 —- 像 AutoGPT 那样全自动分解、规划和执行任务目标,可能并不是最好的方案,我们可以有更好的方式让 AI 完成我们的目标。

引子,与历史

自然语言编程(Natural Language Programming)是一种研究领域,它的目标是让计算机能够理解和执行人类的自然语言指令。这个领域的起源可以追溯到计算机科学的早期阶段。在 20 世纪 50 年代和 60 年代,人们开始尝试使用自然语言来与计算机进行交互。这个时期的研究主要集中在自然语言理解(Natural Language Understanding)上,目标是让计算机能够理解人类的语言。这包括了早期的聊天机器人,如ELIZA 和 SHRDLU。

然而,真正的自然语言编程需要计算机不仅能理解人类的语言,还能根据这些语言来执行任务。只有到最近,使用 LLM 调用 API、AutoGPT 的 Agent 等的出现,才使这一切成为可能。

AutoGPT 的限制

AutoGPT是一个实验性的开源应用,它使用 GPT-4 语言模型来自动执行用户设定的目标。AutoGPT 的核心是自动创建任务并执行任务,它具有接收模糊问题、任务拆解分析和自我成长能力。例如,AutoGPT 的 prompt 之一如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Your task is to devise up to 5 highly effective goals and an appropriate role-based name (_GPT) for an autonomous agent, ensuring that the goals are optimally aligned with the successful completion of its assigned task.

The user will provide the task, you will provide only the output in the exact format specified below with no explanation or conversation.

Example input:
Help me with marketing my business

Example output:
Name: CMOGPT
Description: a professional digital marketer AI that assists Solopreneurs in growing their businesses by providing world-class expertise in solving marketing problems for SaaS, content products, agencies, and more.
Goals:
- Engage in effective problem-solving, prioritization, planning, and supporting execution to address your marketing needs as your virtual Chief Marketing Officer.

- Provide specific, actionable, and concise advice to help you make informed decisions without the use of platitudes or overly wordy explanations.

- Identify and prioritize quick wins and cost-effective campaigns that maximize results with minimal time and budget investment.

- Proactively take the lead in guiding you and offering suggestions when faced with unclear information or uncertainty to ensure your marketing strategy remains on track.

这个prompt的目标是为一个自主代理设定高效的目标,并为其赋予一个基于角色的名称(_GPT)。这个代理的任务是帮助用户完成特定的任务,例如在这个例子中,帮助用户进行商业营销。输入是用户提供的任务,输出是代理的名称、描述和目标。输出的格式非常明确,不包含任何解释或对话;代理的名称是CMOGPT,这是基于其角色——虚拟首席营销官(Chief Marketing Officer)的缩写。描述部分详细阐述了这个代理的专业领域和服务对象,即为独立创业者提供营销问题解决方案。

目标部分列出了四个具体的目标,这些目标都与成功完成其分配任务——帮助用户进行商业营销——紧密相关。这些目标包括有效的问题解决、提供具体和可行的建议、识别和优先处理快速获胜和成本效益高的活动,以及在面临不清晰的信息或不确定性时主动引导用户和提供建议。

这只是 AutoGPT 的 prompt 的一部分,接下来,就可以根据划分的任务目标和具体的任务,进行分析和执行,完成一个复杂的需求。

作为首个完全自主运行的GPT-4示例,AutoGPT 的任务规划、分解、执行的方案是基于 GPT-4 的自然语言生成和理解能力,以及多种数据源和工具的调用。babyAGI 也是类似的思路,它是一个自然语言编程系统,通过创建三个代理(任务执行代理、任务创建代理和任务优先级代理)来执行目标。每个代理都有自己的提示和约束,包括来自每个相关任务执行的上下文。这个过程将循环执行,直到没有剩余的任务并且目标完成。

例如,我们可以尝试给 AutoGPT 两个问题:

测试问题1:ETH和特斯拉,哪一个值得在未来5年内投资?它一开始就会把问题拆成 3 个子任务:收集历史数据表现、分析数据趋势和模式、提供最终建议。对于比较的维度,一开始只有历史表现,然后逐步增加了更多维度,包括风险分析(市场条件、监管变化和其他)、新兴技术、公司财务、盈利能力、未来增长能力等,也就是说,AgentGPT 的思考变的越来越全面.在增加比较维度后,AgentGPT 会主动迭代其答案,给出最新建议。测试问题2:如何在加密货币世界中进行聪明的投资.这个问题比上一个更模糊。没有明确的比较标的,需要 AgentGPT 去寻找和定义。没有明确的投资期限,看 AgentGPT 如何处理。可以同样把这个问题抛给 ChatGPT 和 AgentGPT, 这次 AgentGPT 疯狂拆解和新增子任务,在运行的 20 分钟内生成了 53 个子任务,输出超过了 1.5w 字,最终被手动停止。一开始 AgentGPT 还比较正常,仍然把问题拆成 3 个子任务:收集历史数据表现、分析当前市场趋势和潜在发展、在风险管理策略下提供最终建议。但可能因为没有具体的标的,维度发散起来就收不住:历史数据、市场趋势、未来潜力、风险管理、监管影响、多元投资、市场波动、止损、基本面、流动性、经济事件、社区情绪、KOL 情绪…

很明显,AutoGPT 本身的架构并不是为了执行特定的任务或解决特定的问题而设计的,它会存在很多类似的问题:

  1. 由于GPT-4语言模型的 token 很贵,其使用费用可能很高,但实际上大多数费用可能被浪费在了无意义的探索上面。
  2. 作为一项自主实验,Auto-GPT可能会产生不符合现实世界商业惯例或法律要求的内容或采取的行动。您有责任确保基于本软件输出的任何行动或决定符合所有适用的法律、法规和道德标准。
  3. AutoGPT 会重复类似的问题(没有足够好的记忆和推理、复用之前的结果的能力)、发散、不可预测。

可以发现,即使 AutoGPT 目前具有任务拆解和自我成长能力,但实际上,我们很难完全放手让 GPT 的 agent 在一个开放和模糊的空间中,自行探索和解决问题。这样大概率解决的方案和人类的意图是难以对齐的,最终不得不被停止。尽管还有缺陷,AutoGPT 显示出的能力,依然将人和 AI 的边界继续向前推进了一步。

下一步是什么?

有些对于 AutoGPT 和类似的 AI 系统的改进和解决方案,例如:

  1. 增加对应的任务记忆功能:通过增加记忆功能,AutoGPT可以避免重复执行相同的任务。也能大量节约 token。例如 GPTcache:

传统的缓存系统通常利用一个新的查询和一个缓存查询之间的精确匹配来确定所请求的内容在获取数据之前是否在缓存中可用。然而,由于LLM查询的复杂性和可变性,对LLM缓存使用精确匹配的方法不太有效,导致缓存命中率低。为了解决这个问题,GPTCache采用了语义缓存等替代策略。语义缓存可以识别并存储类似或相关的查询,从而提高缓存的命中率,增强整体缓存效率。

GPTCache采用嵌入算法将查询转换为嵌入,并使用向量存储对这些嵌入进行相似性搜索。这个过程允许GPTCache识别并从缓存存储中检索相似或相关的查询,如模块部分所说明的。

  1. 设定任务拆解的限制:通过在 AutoGPT 中设定任务拆解的限制,可以防止任务过于发散;同时,可以让 AI 自动规划不同的模型和通过token的使用,降低AutoGPT的运行成本。例如,复杂任务可以自动采用 GPT4 的模型,对于简单的翻译使用 GPT 3.5;或者通过 GPT4 进行任务规划和分析,而使用 3.5 执行具体的任务。

更常见的方案是引入人类监督和交互。可以通过人类每隔一段时间,或者在有需要的时候去监督一下 AI 的执行情况,并确保AutoGPT的行为符合现实世界的商业惯例和法律要求。如果不符合人类的意图的话,通过对话可以对 Agent 进行调整,要求它做更符合人类意图的事情(实际上这在多人合作完成任务的场景下,例如公司或组织中,也是非常常见的)。但相对来说,这种方式经常低效而且缓慢:如果我需要监督 AI 才能保证它不出错,为什么我不自己做呢?

有没有更好的方式?

但实际上,在现实世界中,也许我们有更好的方式让 AI agent 和人类的意图对齐。想象这样的场景:你希望一个对某件复杂任务的流程不了解的人,完成一项特定任务:例如上手一个代码项目的开发和环境配置,学习一门新的编程语言,或者编写一本长篇小说、分析某个商业投资的可行性等等。也许这种情况下,我们可以有一本手册或者教程,它并不需要是精确的、一步一步的指令,但是它会包含一个大致的流程和任务分解,让人类能够快速上手完成对应的任务。那么,我们为什么不能用非常轻松的方式,给 AI 一些大概的指示和任务描述,让它根据这些任务来完成对应的工作呢?

相比 AutoGPT,我们实际上需要的是:

  • 更强的可控性,让它和人类意图进行充分的对齐;
  • 比 CoT(思维链)走的更远,让 AI 能够完成更加复杂的任务,同时不仅限于一步步执行,还需要有递归、循环、条件判断等等特性;
  • 可以由人类给予更加明确的指导,让 AI 完成复杂的任务,而不是让 AI 完全自己探索如何分解任务;
  • 保留一定的灵活性和机动能力,同时让编写指令尽可能简单。实际上,我们可能并不需要用代码开发和精确的链条来介入这个流程,现实世界大多数充斥着模糊、不确定、近似和时刻需要动态调整的部分,设计一个合理的抽象反而是非常困难的。langchain 那种以代码开发为核心的形态,也许并不会是一个合理的答案,AutoGPT 的出现也证明了这一点。

根据 wiki 百科,计算机程序(Computer Program)可以定义为指一组指示电子计算机或其他具有消息处理能力的电子设备每一步动作的指令序列。也许,某种意义上它也是一种 “程序”,但并不是传统的编程语言:自然语言适合模糊化、灵活、可高效扩展的需求,而传统的程序设计语言实际上是一种精确的抽象和计算,二者缺一不可,它们可以相互转化,但是并不一定需要是从自然语言描述转化为精确的计算机指令。未来,每个人都可以是程序员,只要能够用自然语言描述出对应的需求和步骤,无论清晰或者模糊。

自然语言编程

自然语言编程不应该是:

1
2
3
4
5
6
7
8
9
10
11
12
+++ proc1
-- Return five random emojis
+++

+++ proc2
-- Modify proc1 to return random numbers instead
-- Let $n = [the number of countries in Latin America]
-- Instead of five, use $n
/execute proc1
+++

/proc2

用自然语言编程模拟计算机程序的编写没有意义,不需要有明确的定义、关键词、语法等等。

自然语言编程是:

1
把 asserts 目录下所有的以 txt 结尾的文档翻译成英文,然后喂给 AI 做训练

或者一个更复杂的例子:

1
2
3
4
5
6
7
8
9
10
11
3 由质谱推测分子结构的一般程序
1)综合评价质谱总图。通常样品的质谱分析,在总离子流色谱图(TIC)中会出现许多峰,由每个峰都可得到相应的质谱图,从这些质谱中选择出合理的质谱图:首先找出总离子流图中对应较强组分的质谱图;观察质谱图中是否出现分子离子峰;主要的碎片离子峰质量是否合理相关;排除不相关的杂质峰;如果谱图中全是奇质量,通常是分子离子峰未出现。
2)分子峰识别,辅助软电离技术,如CI-MSFAB-MSESI等。
3)找出合理重排的奇电离子和结构特征离子峰。
4)重要的低质量区的特征碎片峰。
5)同位素的识别应用。
6)应用高分辨质谱技术可给出分子的元素组成和分子式。能获取高分辨质谱数据当然很理想,遗憾的是大部分双聚焦高分辨质谱仪因高分辨实验操作比较麻烦和仪器状态不佳,要获得1万的分辨率也很难实现;价格高昂的离子回旋共振FT-MS又不够普及。
7) MS-MS质谱-质谱联用。如果仪器具有质谱-质谱联用分析功能,充分利用这种技术,对有足够强度的峰,做二次和多次质谱分析,特别对样品中存在的杂质组分和混合物样品的质谱分析,是非常有效的质谱技术。
8)质谱谱库参考谱图检索。许多质谱仪配带十几万张标准化合物的谱库,可对得到的谱图进行方便的检索,给出相似度较大的化合物。但质谱谱库给出的只是已有化合物的EI-MS谱图,现有的谱库不能给出各种软电离谱图信息和完全未知化合物的结构信息。
9)综合研究能获取的所有光谱结构信息。质谱图要求对大部分较大的谱峰和某些结构特征的小峰作出合理解释;如有条件需要结合红外光谱图和核磁共振谱图进行综合分析,给出可信度最大的分子结构信息。
10)最终确认推测的分子结构,还要通过获取标准物质后做全部化学、光谱信息和应用理化性质作符合检查,最终确定推测化学结构的准确性。

这里是另外一个例子,我们希望使用自然语言,来指导 AI 完成一个长篇小说的创作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
有一个任务叫做“翻译和根据大纲创作小说章节”。
我会给你章节的大纲、背景知识,你需要:
1. 根据大纲和背景知识,写出章节的内容;
2. 把内容翻译成英文。要求翻译质量高,文笔出色。

我想要给你一个任务,叫做作品创作和翻译:
你是一个资深作家和翻译家,我希望你帮忙创建一个长篇小说,并且把它翻译成英文。
我会告诉你需要这个小说的题目是什么,以及大概需要多少个章节。你会按照我的指示一步步完成任务。
首先,这个小说的主题是什么?写出主题。
你要不要上网搜索一下这个小说的主题的一些相关背景?把搜索到的背景资料也整理一下。
之后,根据主题和背景资料,这篇小说的大纲是什么?把它的大纲列出来。
然后,对于每个章节,把章节的大纲和背景列出来,对每个章节继续“翻译和根据大纲创作小说章节”这个任务。
最后,把每个章节拼凑在一起,保存在文件里面。

现在,我想要完成以下的事情:
1. 作品创作和翻译一本关于“猫”的童话小说,一共有10个章节,每个章节大概 500 字。
2. 作品创作和翻译一本关于“猫娘”的科幻小说,一共有10个章节,每个章节大概 1000 字。

在这个情况下,我们可以将自然语言编写的一系列视为一种任务分解和执行的过程。我们可以通过定义一些特定的关键词和语法规则来实现这个目标。我们的目标是将自然语言脚本分解为一系列的指令和参数,然后执行这些指令。

自然语言编程不是,也不应该是通常意义上的编程语言编程。我们并不进行自然语言到代码的转换,没有确定的语法、语言和编程范式,大语言模型就是我们的解释器、CPU 和内存;自然语言适合处理需求模糊、信息密度高的应用,代码适合处理精确、可靠的部分。或者说,自然语言编程实际上是 prompt engineering 的高阶形态,自然语言的指令不再局限于单次和 AI 的交互上下文,并且希望能够借助它来增加和扩展 AI 完成复杂推理、复杂任务执行的能力。

以下是一些可能的步骤和考虑因素:

  1. 函数(任务):在自然语言编程中,函数可以被视为一种任务或者目标。例如,在你的脚本中,“作品创作和翻译”就可以被视为一个函数,它需要接收一些参数(例如,主题、章节数量、每章字数等),然后执行一系列的步骤来完成这个任务。
  2. 变量:全局变量可以被视为在整个脚本中都可以访问的信息、概念、知识。在你的脚本中,例如“小说的主题”、“章节数量”、“每章字数”等都可以被视为全局变量。
  3. 指令:在自然语言编程中,指令可以被视为一种行为或者动作。例如,“写出主题”、“搜索背景资料”、“列出大纲”等都可以被视为指令。这些指令可以是顺序执行的,也可以是选择执行的,甚至可以是循环执行的。
  4. 执行流程:执行流程是指如何按照一定的顺序和逻辑来执行这些指令。在你的脚本中,执行流程可能是这样的:首先,执行“写出主题”这个指令,然后执行“搜索背景资料”这个指令,接着执行“列出大纲”这个指令,然后对于每个章节,执行“翻译和根据大纲创作小说章节”这个指令,最后执行“拼凑章节”和“保存文件”这两个指令。
  5. 分析自然语言脚本:分析自然语言脚本的目标是将脚本中的语句分解为一系列的指令和参数。这可能需要使用一些自然语言处理(NLP)的技术,例如语义分析、实体识别等。例如,从“作品创作和翻译一本关于“猫”的童话小说,一共有10个章节,每个章节大概 500 字。”这个语句中,我们可以识别出“作品创作和翻译”是一个函数,“猫”、“童话小说”、“10个章节”、“每章500字”是这个函数的参数。
  6. 执行指令:执行指令的目标是按照分析出的指令和参数来完成任务。这可能需要调用一些外部的API或者服务。例如,执行“翻译和根据大纲创作小说章节”这个指令可能需要调用一个文本生成的API,执行“翻译”这个指令可能需要调用一个翻译的API。

一个可能的思路

为了正确、可控地执行它,我们首先定义函数(任务)、变量和执行流程,然后通过一系列的提示来提取这些信息,并最后让AI处理对应的执行流程。以下是一种可能的方式,以及示例的提示信息(实际上的提示比这个复杂得多):

  1. 定义和提取函数(任务)
    • 函数(任务)是一个具有明确输入和输出的可复用单元。在自然语言脚本中,函数(任务)通常是一些需要完成的目标或动作。
    • 示例提示:请在你的脚本中找出所有的任务或目标。这些通常是一些动词短语,例如“写出主题”、“搜索背景资料”、“列出大纲”等。例如,”作品创作和翻译”可以被视为一个函数,它需要接收一些参数(例如,主题、章节数量、每章字数等),然后执行一系列的步骤来完成这个任务。
  2. 定义和提取变量
    • 变量是在整个脚本中都可以访问的信息,它可以是一些知识、概念、信息,一段文本等等。
    • 示例提示:请在你的脚本中找出所有的变量。这些通常是一些名词或名词短语,例如“小说的主题”、“章节数量”、“每章字数”等。
  3. 定义和提取执行流程
    • 执行流程是指如何按照一定的顺序和逻辑来执行这些指令。在你的脚本中,执行流程可能是这样的:首先,执行“写出主题”这个指令,然后执行“搜索背景资料”这个指令,接着执行“列出大纲”这个指令,然后对于每个章节,执行“翻译和根据大纲创作小说章节”这个指令,最后执行“拼凑章节”和“保存文件”这两个指令。
    • 示例提示:请在你的脚本中找出每个任务或目标的执行流程。这通常是一系列的步骤或指令,例如“首先,写出主题,然后,搜索背景资料,接着,列出大纲,然后对于每个章节,翻译和根据大纲创作小说章节,最后,拼凑章节,保存文件”。
  4. 处理执行流程
    • 根据提取出的执行流程,AI需要处理对应的执行流程。根据循环、分支、顺序执行做出不同的处理。
    • 示例提示:现在,我将开始根据提取出的执行流程来处理每个任务。例如,对于“翻

译和根据大纲创作小说章节”这个任务,我将首先根据大纲创作章节的内容,然后将内容翻译成英文。

这样的流程可以帮助我们将自然语言脚本转化为可执行的任务。但是请注意,这需要一些自然语言处理和分析的技能,以及对脚本内容的深入理解。此外,AI的处理能力也会受到一些限制,例如,它可能无法直接处理一些过于复杂的任务(可以在脚本中进一步划分和明确任务),或者需要人类的帮助来完成一些步骤。

自然语言脚本编译器与运行时:langScript

我们做了一个简单的开源项目实验,来完成这个目标和执行自然语言编写的脚本 —- 它不需要有完全确定的语法和格式,也并不需要像学习一门编程语言一样学习它。本质上,它只是对于需求和任务稍微明确一些的指引和抽象,我们可以更方便地使用自然语言定义流程和指导 AI 完成复杂工作。

未来:AI 还是软件?

也许我们很快就要重新思考软件工程是什么这个问题:

  • AI 和更广义的 “软件” 之间有什么区别呢?
  • 一个软件中的哪些部分可以被 AI 替代?
  • AI 可以怎样重塑软件的生命周期、开发方式?
  • 自然语言的部分和代码的部分各有什么优势和劣势?哪些情况下我们可以直接使用自然语言,哪些情况下需要代码进行抽象?
  • 在一个软件的组成部分中,用 AI 替换掉代码,能带来哪些变革? 会不会能更好的完成对应的需求?
  • 更进一步,一个完整的信息系统(以处理信息流为目的的人机一体化系统,有软件、硬件和人类的部分)和 AI 的关系是什么? AI在信息中是什么样的地位? AI 会怎样从信息的角度重塑我们的社会结构和组织模式?

目前来看,我们还远远没有答案。

原 Netscape(网景公司)创始人 Marc Andreessen 说过一句经典的话:软件正在吞噬世界。人工智能领域知名科学家 Andrej Karpathy 在 2017 年为上面的话做了补充:软件(1.0)正在吞噬世界,现在人工智能(软件2.0)正在吞噬软件

Software (1.0) is eating the world, and now AI (Software 2.0) is eating software.

GitHub Copilot 背后的 AI 编码技术:如何让 GPT 更好地理解你的代码

AI 编码工具正在逐渐改变我们的编程习惯和体验,GitHub Copilot 是这一变革的最佳代表。这款产品利用强大的算法,根据开发者的上下文和需求,从多个来源挑选出最相关的代码片段和注释,进而生成编码建议。在这篇文章中,我们将深入探讨 GitHub Copilot 背后的技术思路,结合通过逆向工程获取的部分 copilot prompt,希望能为构建 AI 辅助的编码工具提供一些帮助。这里的内容部分翻译整理自微软的博客,我们也会讨论其他人的研究成果和观点。

GitHub Copilot 通过算法从多个来源选择相关代码片段或注释,并使用上下文理解能力生成编码建议。GitHub Copilot 创建了精巧的提示词工程的方案,并在提示库中优先处理有关开发人员上下文的信息,使用向量数据库为在私有存储库中或处理专有代码的开发人员创建了定制化的编码体验。

为了让使用 GitHub Copilot 的开发人员感觉像是与另一个程序员合作,GitHub的机器学习专家一直在研究、开发和测试新功能,其中很多都集中在提高 AI 程序员的上下文理解能力。这是因为良好的交流对于合作编程至关重要,推断上下文对于实现良好的交流至关重要。

为了揭示这些背后的工作,原文作者向 GitHub 的研究员和工程师询问了他们正在做的帮助 GitHub Copilot 提高上下文理解能力的工作。以下是他们发现的内容。

从OpenAI的Codex模型到GitHub Copilot

当OpenAI在2020年6月发布GPT-3时,GitHub 知道开发人员将从专门为编码利用该模型的产品中受益。因此,他们向 OpenAI 提供输入,帮助其构建 Codex,它是GPT-3和LLM的后代,将驱动GitHub Copilot。这款程序员配对工具于2021年6月发布为技术预览版,并于2022年6月成为世界上第一个大规模生成式AI编码工具。

为确保该模型具有最佳信息,以便快速做出最佳预测,GitHub的机器学习(ML)研究员进行了大量称为提示工程的工作(将在下面详细解释),以便模型提供上下文相关的响应,并具有低延迟。

尽管GitHub总是在尝试新模型,但Codex是第一个真正强大的生成式AI模型,也是可用的,GitHub的机器学习工程师David Slater表示:“我们从模型和提示改进的迭代中获得的实践经验是非常宝贵的。”

所有这些实验最终导致了一款 pair programming 工具,最终“释放了开发人员的时间,让他们专注于更有意义的工作”。该工具甚至对于从头开始启动新项目或文件也是一个巨大的帮助,因为它提供了一个开发人员可以根据需要进行调整和改进的起点。GitHub的机器学习研究员Alice Li表示。

为什么上下文很重要

开发人员使用拉取请求、项目文件夹、开放问题等详细信息来确定其代码的上下文。当涉及到生成AI编码工具时,Copilot 需要教这个工具使用哪些信息来做同样的事情。

Transformer LLMs 擅长连接和大局思考。生成AI 编码工具是由大型语言模型(LLMs)所支持。这些模型是在大量代码和人类语言上训练的算法集。今天的最先进的 LLMs 是 transformer ,这使它们能够在用户输入的文本和模型已经生成的输出之间建立联系,这就是为什么今天的生成AI工具提供比之前的AI模型更具上下文相关性的响应。

但是,AI 需要被告知哪些信息与您的代码相关。目前,足够快速以支持GitHub Copilot的 transformer 每次只能处理大约6,000个字符。虽然这已足以推进和加速代码完成和代码更改总结等任务,但有限的字符数量意味着无法使用开发人员的所有代码作为上下文。

因此,Copilot 的挑战是找出不仅要向模型提供哪些数据,还要如何最好地排序和输入它以获得最佳建议。

GitHub Copilot 如何理解您的代码

一切都归结于提示,这些提示是集成IDE代码和相关上下文的编译,供模型使用。提示由后台算法生成,可以在您编码的任何时候生成编码建议。这就是为什么GitHub Copilot会生成编码建议,无论您是正在编写还是刚刚完成注释,或者正在处理一些复杂的代码。

  • 下面是提示的创建过程:首先,一系列算法从当前文件和其他来源中选择相关代码片段或注释。然后,对这些片段和注释进行优先排序,过滤和组装,形成最终的提示。

GitHub Copilot的上下文理解能力不断成熟。第一个版本只能认为您在IDE中工作的文件与上下文相关。但 Copilot 团队知道上下文超出了这个范围。现在,仅仅一年后,他们正在尝试使用算法来考虑您的整个代码库,以生成定制的建议。

他们如何到达这里的

  • 提示工程是创建提示的精细艺术,以便模型为用户提供最有用的预测。提示告诉包括GitHub Copilot在内的LLMs,要处理哪些数据,以及以什么顺序来对您的代码进行上下文化。大部分工作都在所谓的提示库中进行,这是专家与算法一起提取和优先处理有关开发人员上下文的各种信息的地方,创建将由GitHub Copilot模型处理的提示。

  • 相邻选项卡是称之为允许GitHub Copilot处理开发人员IDE中打开的所有文件的技术,而不仅仅是开发人员正在处理的单个文件的技术。通过打开与其项目相关的所有文件,开发人员自动调用GitHub Copilot来扫描所有数据,并在其光标周围的代码之间找到匹配的代码片段,并将这些匹配项添加到提示中。

开发相邻选项卡时,GitHub Next团队和内部ML研究人员进行了A/B测试,以确定识别IDE中代码和打开选项卡中代码之间匹配的最佳参数。他们发现设置非常低的门槛来包括匹配实际上会提供最佳的编码建议

通过包含每一个小的上下文,相邻选项卡帮助相对增加了5%的用户接受GitHub Copilot的建议。

  • Fill-In-the-Middle(FIM)范式进一步扩大了上下文孔径。在FIM之前,只有光标之前的代码会被放入提示中,而忽略了光标之后的代码。(在GitHub上,将光标之前的代码称为前缀,将光标之后的代码称为后缀)。使用FIM,我们可以告诉模型提示的哪一部分是前缀,哪一部分是后缀

即使您从头开始创建文件,并且只有文件的框架,Copilot 也知道编码不是线性或顺序的。因此,当您在文件中跳来跳去时,FIM可以帮助GitHub Copilot为您的文件中光标所在的部分或前缀和后缀之间应该出现的代码提供更好的编码建议。

基于A/B测试,FIM提高了10%的相对性能,这意味着开发人员接受了他们所看到的建议中的10%以上。由于最佳的缓存使用,相邻选项卡和FIM在后台运行,不会增加任何延迟。

图片

也许结合实际的代码来理解会更好一些。例如,在逆向出来的 Github Copilot 插件代码中,在构建 prompt 时包含了以下的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
exports.Priorities =
  exports.PromptWishlist =
  exports.PromptElementRanges =
  exports.PromptChoices =
  exports.PromptBackground =
  exports.PromptElementKind =
    undefined;
const M_prompt_parsing_utils_maybe = require("prompt-parsing-utils");
const M_tokenizer_maybe = require("tokenizer");
var i;
!(function (e) {
  e.BeforeCursor = "BeforeCursor";
  e.AfterCursor = "AfterCursor";
  e.SimilarFile = "SimilarFile";
  e.ImportedFile = "ImportedFile";
  e.LanguageMarker = "LanguageMarker";
  e.PathMarker = "PathMarker";
})((i = exports.PromptElementKind || (exports.PromptElementKind = {})));

这段代码的主要作用是定义了一种枚举类型,用于表示 GitHub Copilot 系统内部使用的各种提示元素类型,并导入了一些可能与这些提示元素处理相关的模块。它定义了一些名为 PromptElementKind 的变量,该变量是一个枚举对象,用于描述不同类型的提示元素。提示元素可能是在编码过程中需要的不同种类的信息,例如光标之前或之后的代码、相似的文件、已导入的文件、语言标记和路径标记。这些提示可能包括从当前文件和其他来源选择的相关代码片段或注释,然后对这些片段和注释进行优先排序、过滤和组装,形成最终的提示。这些提示告诉模型处理哪些数据,以及以什么顺序来对代码进行上下文化。

一个实际的 Copilot 的提示的示例可能是这样的:

1
2
3
4
5
6
7
8
9
10
11
{
  "prefix": "# Path: codeviz\\\\app.py\\n# Compare this snippet from codeviz\\\\predictions.py:\\n# import json\\n# import sys\\n# import time\\n# from manifest import Manifest\\n# \\n# sys.path.append(__file__ + \\"/..\\")\\n# from common import module_codes, module_deps, module_categories, data_dir, cur_dir\\n# \\n# gold_annots = json.loads(open(data_dir / \\"gold_annotations.js\\").read()",
  "suffix": "if __name__ == '__main__':\\r\\n    app.run(debug=True)",
  "isFimEnabled": true,
  "promptElementRanges": [
    { "kind": "PathMarker", "start": 0, "end": 23 },
    { "kind": "SimilarFile", "start": 23, "end": 2219 },
    { "kind": "BeforeCursor", "start": 2219, "end": 3142 }
  ]
}

正如您所见,此提示包括前缀和后缀。然后,Copilot将此提示(经过一些格式化)发送到模型。在这种情况下,Copilot正在以“插入模式”(也称为填写中间(FIM)模式)调用 Codex,因为后缀不为空。

提高语义理解能力

今天,Copilot 正在尝试使用向量数据库,可以为在私有存储库中或处理专有代码的开发人员创建定制编码体验。生成式AI编码工具使用称为嵌入的东西从向量数据库中检索信息。

  • 什么是向量数据库? 它是一个索引高维向量的数据库。

  • 什么是高维向量? 它们是对象的数学表示,因为这些向量可以以多个维度模拟对象,所以它们可以捕捉该对象的复杂性。当适当地用于表示代码片段时,它们可以表示代码的语义甚至意图,而不仅仅是语法。

  • 什么是嵌入? 在编码和LLM的上下文中,嵌入是将代码片段表示为高维向量的方式。由于LLM对编程和自然语言的“知识”,它能够在向量中捕捉代码的语法和语义。

以下是它们如何共同运作的方式:

  • 算法将为存储库中的所有片段(可能是数十亿个)创建嵌入,并将其存储在向量数据库中。

  • 然后,当您编码时,算法会在IDE中嵌入片段。

  • 算法随后会在嵌入到IDE片段和已存储在向量数据库中的嵌入之间进行近似匹配,也是实时的。向量数据库是使算法能够快速搜索向量上的近似匹配(而不仅仅是精确匹配),即使它存储了数十亿个嵌入的原因。

开发人员熟悉使用哈希码检索数据,这通常会寻找精确的字符匹配,GitHub的高级ML研究员Alireza Goudarzi解释说。“但嵌入——因为它们来自于经过大量数据培训的LLM——会在代码片段和自然语言提示之间建立语义上的接近感。”

阅读以下三个句子,并确定哪两个语义最相似。

  • 句子A:国王移动并俘获了兵。

  • 句子B:国王在威斯敏斯特大教堂加冕。

  • 句子C:两个白色的战车仍然在比赛中。

答案是句子A和C,因为两者都是关于国际象棋的。虽然句子A和B在语法上或结构上相似,因为国王是主语,但它们在语义上是不同的,因为“国王”在不同的上下文中使用。

以下是每个语句如何转换为Python。请注意,尽管它们在语义上不同,但片段A和B之间具有语法上的相似性,而片段A和C之间具有语义上的相似性。

片段A:

1
2
3
if king.location() == pawn.location():
board.captures_piece(king, pawn)

片段B:

1
2
3
if king.location() == "Westminster Abbey":
king.crown()

片段C:

1
2
3
if len([ r for r in board.pieces("white") if r.type == "rook" ]) == 2:
return True

如上所述,Copilot 仍在尝试检索算法,并正在为企业客户设计该功能,特别是那些正在寻找定制编码体验的私有存储库,并明确选择使用该功能。

我们可以进一步结合逆向工程的 copilot 代码讨论这个问题。为了处理大规模的自然语言处理任务, Copilot 在客户端使用了 Cushman + ONNX 模型处理。具体来说,Copilot 将 Cushman 模型的输出转换为向量表示,然后使用向量相似度计算来匹配最相关的本地文件。

除了就地矢量化(Vector)与相似度匹配,Copilot 还使用了本地的相似计算与 token 处理来管理 token,以便更好地处理大规模自然语言处理任务。例如,在 Copilot 逆向工程中出现的可能的代码片段如下:

1
2
3
4
5
6
7
8
9
10
11
e.prototype.useAutoCorrelation = function (e, t) {
    if (e && !this._isAutoCorrelating) {
      M_correlation_context_manager.CorrelationContextManager.enable(t);
    } else {
      if (!e && this._isAutoCorrelating) {
        M_correlation_context_manager.CorrelationContextManager.disable();
      }
    }
    this._isAutoCorrelating = e;
  };

总结与回顾

去年,Copilot 团队对 GitHub Copilot 进行了定量研究,发现使用这个软件的开发者能够以高达 55% 的速度编码。这意味着开发者感到更加高效,能够更快地完成重复性任务,并且能够更专注于令人满意的工作。但是我们的工作不会止步于此。

GitHub 产品和研发团队,包括 GitHub Next,一直在与 Microsoft Azure AI 平台 合作,继续改进 GitHub Copilot 的上下文理解。许多帮助 GitHub Copilot 理解您的代码的工作都是在幕后进行的。当您编写和编辑您的代码时,GitHub Copilot 会实时响应您的写作和编辑,通过生成提示(或者说,基于您在 IDE 中的操作,优先排序并发送相关信息给模型)来不断给出最佳编码建议。

了解更多

  • GitHub Copilot X 是对于以 AI 为动力的软件开发的未来设想。发现新内容。
  • 了解 支持 GitHub Copilot 的 LLMs 如何变得更加优秀。
  • 阅读对应的研究,了解 GitHub Copilot 如何影响开发者的生产力。

Copilot 逆向工程相关资料:

ChatGPT 插件开发者教程与最佳实践 - 快速上手开发 plugins

我这两天也用上了 ChatGPT 的插件,感觉还是挺好玩的(也许是被 Google 的 AI 和微软逼出来的吧),开发一个插件实际上一点也不难,出乎意料地简单。

本文介绍了如何使用 OpenAPI 规范记录 API,以及如何将插件连接到ChatGPT UI。同时,还提供了编写插件描述和调试插件的最佳实践。通过定义 OpenAPI 规范以及清单文件,可以来创建一个待办事项列表插件。这里还有基于 Vercel 平台的开发者模板,可以帮助您轻松开发和部署 ChatGPT 插件,并一键上线使用:https://github.com/yunwei37/ChatGPT-plugin-vercel-template

本文部分整理自 OpenAI 的官方文档。

创建插件需要3个步骤:

  1. 构建API

  2. 以OpenAPI yaml或JSON格式文档化API

  3. 创建一个JSON清单文件,用于为插件定义相关元数据

本节的重点将是通过定义OpenAPI规范以及清单文件来创建一个待办事项列表插件。

可以在 OpenAI 的仓库中 浏览示例插件,涵盖多种用例和身份验证方法。

插件清单

每个插件都需要一个ai-plugin.json文件,它需要托管在 API 的域中。

例如,名为example.com 的公司将通过 https://example.com 域使插件JSON文件可访问,因为它们的API被托管在该域中。当您通过ChatGPT UI安装插件时,在后端我们会查找位于/.well-known/ai-plugin.json的文件。/.well-known文件夹是必需的,并且必须存在于您的域中,以便ChatGPT与您的插件连接。如果找不到文件,则无法安装插件。对于本地开发,您可以使用HTTP,但如果指向远程服务器,则需要使用HTTPS。

所需的ai-plugin.json 文件的最小定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
    "schema_version": "v1",
    "name_for_human": "TODO Plugin",
    "name_for_model": "todo",
    "description_for_human": "Plugin for managing a TODO list. You can add, remove and view your TODOs.",
    "description_for_model": "Plugin for managing a TODO list. You can add, remove and view your TODOs.",
    "auth": {
        "type": "none"
    },
    "api": {
        "type": "openapi",
        "url": "http://localhost:3333/openapi.yaml",
        "is_user_authenticated": false
    },
    "logo_url": "http://localhost:3333/logo.png",
    "contact_email": "support@example.com",
    "legal_info_url": "http://www.example.com/legal"
}

如果您想查看插件文件的所有可能选项,请参考以下定义。在命名插件时,请牢记我们的插件 指南,不遵守这些指南的插件将不会被批准放入插件商店。

Field Type Description / Options Required
schema_version String Manifest schema version
name_for_model String Name the model will use to target the plugin (no spaces allowed, only letters and numbers). 50 character max.
name_for_human String Human-readable name, such as the full company name. 20 character max.
description_for_model String Description better tailored to the model, such as token context length considerations or keyword usage for improved plugin prompting. 8,000 character max.
description_for_human String Human-readable description of the plugin. 100 character max.
auth ManifestAuth Authentication schema
api Object API specification
logo_url String URL used to fetch the logo. Suggested size: 512 x 512. Transparent backgrounds are supported.
contact_email String Email contact for safety/moderation, support, and deactivation
legal_info_url String Redirect URL for users to view plugin information
HttpAuthorizationType HttpAuthorizationType “bearer” or “basic”
ManifestAuthType ManifestAuthType “none”, “user_http”, “service_http”, or “oauth”
interface  BaseManifestAuth BaseManifestAuth type: ManifestAuthType; instructions: string;
ManifestNoAuth ManifestNoAuth No authentication required: BaseManifestAuth & { type: ‘none’, }
ManifestAuth ManifestAuth ManifestNoAuth, ManifestServiceHttpAuth, ManifestUserHttpAuth, ManifestOAuthAuth

以下是使用不同身份验证方法的示例:

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
# App-level API keys
type ManifestServiceHttpAuth  = BaseManifestAuth & {
  type: 'service_http';
  authorization_type: HttpAuthorizationType;
  verification_tokens: {
    [service: string]?: string;
  };
}

# User-level HTTP authentication
type ManifestUserHttpAuth  = BaseManifestAuth & {
  type: 'user_http';
  authorization_type: HttpAuthorizationType;
}

type ManifestOAuthAuth  = BaseManifestAuth & {
  type: 'oauth';

  # OAuth URL where a user is directed to for the OAuth authentication flow to begin.
  client_url: string;

  # OAuth scopes required to accomplish operations on the user's behalf.
  scope: string;

  # Endpoint used to exchange OAuth code with access token.
  authorization_url: string;

  # When exchanging OAuth code with access token, the expected header 'content-type'. For example: 'content-type: application/json'
  authorization_content_type: string;

  # When registering the OAuth client ID and secrets, the plugin service will surface a unique token.
  verification_tokens: {
    [service: string]?: string;
  };
}

上述清单文件中某些字段的长度有限制,这些限制可能会发生变化。我们还对 API 响应正文强制实施 10 万字符的最大值,这个值也可能会随时间变化而改变。

总的来说,最佳实践是尽可能简洁地描述和响应,因为模型有限的上下文窗口。

OpenAPI 定义

下一步是构建 OpenAPI规范 来记录API。ChatGPT模型除了OpenAPI规范和清单文件中定义的内容之外,不知道关于您的API的任何信息。这意味着如果您有一个广泛的API,您不需要将所有功能暴露给模型,可以选择特定的端点。例如,如果您有一个社交媒体API,您可能希望让模型通过GET请求访问站点内容,但防止模型能够评论用户的帖子,以减少垃圾邮件的机会。

OpenAPI规范是包装在您的API之上的外壳。基本的OpenAPI规范将如下所示:

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
openapi: 3.0.1
info:
  title: TODO Plugin
  description: A plugin that allows the user to create and manage a TODO list using ChatGPT.
  version: 'v1'
servers:
  - url: http://localhost:3333
paths:
  /todos:
    get:
      operationId: getTodos
      summary: Get the list of todos
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/getTodosResponse'
components:
  schemas:
    getTodosResponse:
      type: object
      properties:
        todos:
          type: array
          items:
            type: string
          description: The list of todos.

我们首先定义规范版本、标题、描述和版本号。当在 ChatGPT 中运行查询时,它将查看在信息部分中定义的描述,以确定插件是否与用户查询相关。您可以在编写描述部分了解更多提示信息。

请记住以下 OpenAPI 规范的限制,这些限制可能会发生变化:

  • API 规范中每个 API 端点描述 / 摘要字段的最大字符数为200个字符

  • API规范中每个API参数描述字段的最大字符数为200个字符

由于我们正在本地运行此示例,因此我们希望将服务器设置为指向您的本地主机 URL 。其余的 OpenAPI 规范遵循传统的 OpenAPI 格式,您可以通过各种在线资源 了解有关OpenAPI格式的更多信息。还有许多工具可以根据您的基础 API 代码自动生成 OpenAPI 规范。

运行插件

创建API、清单文件和OpenAPI规范之后,您现在可以通过ChatGPT UI连接插件。您的插件可能在本地开发环境或远程服务器上运行。

如果您有一个本地版本的API正在运行,您可以将插件界面指向您的本地主机。要将插件与ChatGPT连接,请导航到插件商店并选择“开发您自己的插件”。输入您的本地主机和端口号(例如localhost:3333)。请注意,目前仅支持none认证类型进行本地主机开发。

如果插件正在远程服务器上运行,则需要首先选择“开发您自己的插件”进行设置,然后选择“安装未经验证的插件”进行安装。您只需将插件清单文件添加到yourdomain.com/.well-known/路径并开始测试API即可。但是,对于清单文件的后续更改,您将不得不将新更改部署到公共站点上,这可能需要很长时间。在这种情况下,我们建议设置本地服务器以充当API的代理。这样,您可以快速原型化OpenAPI规范和清单文件的更改。

编写插件描述

当用户提出一个可能是插件请求的查询时,模型会查看OpenAPI规范中端点的描述以及清单文件中的“description_for_model”。与提示其他语言模型一样,您需要尝试多个提示和描述,以查看哪个效果最好。

OpenAPI规范本身是提供有关API各种细节的好地方,例如可用的功能以及其参数等。除了为每个字段使用富有表现力的、信息丰富的名称外,规范还可以针对每个属性包含“描述”字段。这些描述可用于提供自然语言描述,例如功能的作用或查询字段期望的信息。模型将能够看到这些描述,并指导其使用API。如果某个字段仅限于某些值,您还可以提供具有描述性类别名称的“枚举”。

“description_for_model”属性为您提供了自由,使您可以指示模型通常如何使用您的插件。总的来说,ChatGPT背后的语言模型非常擅长理解自然语言并遵循指示。因此,这是一个很好的地方,可以放置有关您的插件的一般说明以及模型应如何正确使用它的说明。使用自然语言,最好是简洁而又描述性和客观的语气。您可以查看一些示例,以了解这应该是什么样子。我们建议用“Plugin for…”开头,然后列出您的API提供的所有功能。

最佳实践

以下是编写description_for_model和OpenAPI规范中的描述以及设计API响应时应遵循的最佳实践:

  1. 描述不应尝试控制ChatGPT的情绪、个性或确切的响应。ChatGPT的设计目的是编写适当的插件响应。

    不良示例:

    当用户要查看待办事项清单时,总是回复“我能找到您的待办事项清单!您有[x]个待办事项:[列出待办事项]。如果您想要,我可以添加更多待办事项!”

    良好示例:

    [不需要提供描述]

  2. 描述不应鼓励ChatGPT在用户未要求使用插件的特定服务类别时使用插件。

    不良示例:

    每当用户提到任何类型的任务或计划时,都要问他们是否想要使用 TODO 插件将某些内容添加到他们的待办事项清单中。

    良好示例:

    TODO 列表可以添加、删除和查看用户的待办事项。

  3. 描述不应规定 ChatGPT 使用插件的具体触发器。ChatGPT 的设计是在适当时自动使用插件。

    不良示例:

    当用户提到任务时,请回复:“您是否希望我将此添加到您的待办事项清单中?说 ‘是’ 继续。”

    良好示例:

[不需要提供描述]

  1. 插件 API 响应应返回原始数据而不是自然语言响应,除非必要。ChatGPT 将使用返回的数据提供自己的自然语言响应。

    不良示例:

    我能找到您的待办事项清单!您有2个待办事项:买东西和遛狗。如果您想要,我可以添加更多待办事项!

    良好示例:

    {“todos”: [“买东西”, “遛狗”]}

调试插件

默认情况下,聊天不会显示插件调用和未向用户显示的其他信息。为了更全面地了解模型与您的插件的交互方式,您可以在与插件交互后单击插件名称后面的向下箭头以查看请求和响应。

模型对插件的调用通常包括来自模型的包含 JSON 类参数的消息,这些参数被发送到插件,随后是插件的响应,最后是模型利用插件返回的信息发送的消息。

如果您正在开发一个本地主机插件,您还可以通过转到“设置”并切换“打开插件开发工具”来打开开发人员控制台。从那里,您可以看到更详细的日志和 “刷新插件” ,它会重新获取插件和OpenAPI规范。

本文介绍了如何使用OpenAPI规范记录API,以及如何将插件连接到ChatGPT UI。同时,还提供了编写插件描述和调试插件的最佳实践。OpenAPI规范包括版本、标题、描述和版本号等基本信息,还可以为每个属性提供自然语言描述,例如功能的作用或查询字段期望的信息。调试插件可以通过向下箭头查看请求和响应,或通过开发人员控制台查看更详细的日志。

开发模板

这里还有一个基于 Vercel 平台的 ChatGPT 插件起始模板,可以帮助您轻松地部署 ChatGPT 插件,并实现一键上线服务:https://github.com/yunwei37/ChatGPT-plugin-vercel-template

点击原文链接即可查看。

参考资料

AI 插件:未来的浏览器、前端与交互

想象一下,你在浏览器中粘贴一个 URL,这个 URL 不仅仅是一个网址,而是一个功能强大、能执行多种任务的 AI 插件。这听起来像是未来的事情,但实际上,这种变革已经悄悄进行中。

1. 插件的魅力与局限性

当我第一次接触到 ChatGPT 插件时,我被其简洁高效的设计所吸引。在我看来,插件不仅仅是一个功能扩展,它其实是未来的网络和前端的一种新形态。简单说,它就像是一个“超级浏览器”,在其中,每个插件都有其独特的功能和特色,而我们只需简单地调用它。

2. 从 UNIX 哲学到 AI 插件

UNIX 的哲学是“做一件事,做得好”。而在今天,这种哲学正在逐渐体现在 AI 插件中。想象一下,每一个 URL 都是一个功能强大的 AI agent,你不需要跳转到多个页面或下载多个应用,只需要调用相应的 AI 插件,你想要的功能就能得到实现。而随着 AI 技术的进步,这种可能性正在成为现实。

3. AI 时代的前端和交互界面

如果说以前的前端开发是构建复杂的逻辑和界面,那么在 AI 时代,这一切都将变得更加简洁。未来的前端不再是复杂的代码和交互逻辑,而是一个能与 AI 无缝对接的界面。UI 仍将存在,但其背后的逻辑将大大简化。这种变革不仅会使开发变得更加简单,也将为用户带来前所未有的体验。

4. AI 的语言:API 的重要性

在 AI 时代,API 将成为与 AI 交流的桥梁。而为了实现这一点,我们不仅需要为人类提供清晰的 API 文档,还要为 AI 提供更为详细的自然语言描述。这样,AI 就能更好地理解我们的需求,并为我们提供更精准的服务。

5. 未来的软件研发与人工智能

未来的软件研发将分为两类人:使用 AI 实现需求的人和研究 AI 的人。这意味着,我们每个人都有可能成为一个“程序员”。不再是复杂的代码和算法,只需要调用 AI,你的想法就能变为现实。

结论

我们正在步入一个全新的 AI 时代,在这个时代中,AI 插件将彻底改变我们的上网习惯和前端开发方式。它不仅仅是一个技术的变革,更是一场思维方式的革命。而在这场革命中,每个人都有机会成为参与者和创新者。